aoaoe 0.149.0 → 0.156.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/dist/index.js +127 -1
- package/dist/input.d.ts +21 -0
- package/dist/input.js +98 -2
- package/dist/tui.d.ts +65 -2
- package/dist/tui.js +190 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { wakeableSleep } from "./wake.js";
|
|
|
16
16
|
import { classifyMessages, formatUserMessages, buildReceipts, shouldSkipSleep, hasPendingFile, isInsistMessage, stripInsistPrefix } from "./message.js";
|
|
17
17
|
import { TaskManager, loadTaskDefinitions, loadTaskState, formatTaskTable, importAoeSessionsToTasks } from "./task-manager.js";
|
|
18
18
|
import { runTaskCli, handleTaskSlashCommand, quickTaskUpdate } from "./task-cli.js";
|
|
19
|
-
import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport, formatQuietStatus, formatSessionAge } from "./tui.js";
|
|
19
|
+
import { TUI, hitTestSession, nextSortMode, SORT_MODES, formatUptime, formatClipText, loadTuiPrefs, saveTuiPrefs, validateGroupName, CONTEXT_BURN_THRESHOLD, buildSnapshotData, formatSnapshotJson, formatSnapshotMarkdown, formatBroadcastSummary, rankSessions, TOP_SORT_MODES, formatIdleSince, CONTEXT_CEILING_THRESHOLD, buildSessionStats, formatSessionStatsLines, formatStatsJson, validateSessionTag, validateColorName, computeErrorTrend, parseQuietHoursRange, computeCostSummary, formatSessionReport, formatQuietStatus, formatSessionAge, formatHealthTrendChart, isOverBudget, DRAIN_ICON } from "./tui.js";
|
|
20
20
|
import { isDaemonRunningFromState } from "./chat.js";
|
|
21
21
|
import { sendNotification, sendTestNotification } from "./notify.js";
|
|
22
22
|
import { startHealthServer } from "./health.js";
|
|
@@ -1023,6 +1023,54 @@ async function main() {
|
|
|
1023
1023
|
tui.log("system", `quiet hours: ${specs.join(", ")} — watchdog+burn alerts suppressed`);
|
|
1024
1024
|
persistPrefs();
|
|
1025
1025
|
});
|
|
1026
|
+
// wire /flap-log
|
|
1027
|
+
input.onFlapLog(() => {
|
|
1028
|
+
const log = tui.getFlapLog();
|
|
1029
|
+
if (log.length === 0) {
|
|
1030
|
+
tui.log("system", "flap-log: no flap events recorded");
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
tui.log("system", `flap-log: ${log.length} event${log.length !== 1 ? "s" : ""}:`);
|
|
1034
|
+
for (const e of log.slice(-20)) {
|
|
1035
|
+
const time = new Date(e.ts).toLocaleTimeString();
|
|
1036
|
+
tui.log("system", ` ${time} ${e.title}: ${e.count} changes in window`);
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
// wire /drain and /undrain
|
|
1040
|
+
input.onDrain((target, drain) => {
|
|
1041
|
+
const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
|
|
1042
|
+
const ok = drain ? tui.drainSession(num ?? target) : tui.undrainSession(num ?? target);
|
|
1043
|
+
if (ok) {
|
|
1044
|
+
tui.log("system", `${drain ? "drain" : "undrain"}: ${target} ${drain ? `marked draining (${DRAIN_ICON})` : "restored"}`);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
tui.log("system", `session not found: ${target}`);
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
// wire /export-all bulk export
|
|
1051
|
+
input.onExportAll(() => {
|
|
1052
|
+
const sessions = tui.getSessions();
|
|
1053
|
+
if (sessions.length === 0) {
|
|
1054
|
+
tui.log("system", "export-all: no sessions");
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const now = Date.now();
|
|
1058
|
+
const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
1059
|
+
const dir = join(homedir(), ".aoaoe");
|
|
1060
|
+
try {
|
|
1061
|
+
mkdirSync(dir, { recursive: true });
|
|
1062
|
+
// snapshot JSON
|
|
1063
|
+
const snapData = buildSnapshotData(sessions, tui.getAllGroups(), tui.getAllNotes(), tui.getAllFirstSeen(), tui.getSessionErrorCounts(), tui.getAllBurnRates(now), pkg ?? "dev", now);
|
|
1064
|
+
writeFileSync(join(dir, `snapshot-${ts}.json`), formatSnapshotJson(snapData), "utf-8");
|
|
1065
|
+
// stats JSON
|
|
1066
|
+
const statEntries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now, new Map(sessions.map((s) => [s.id, tui.getSessionErrorTimestamps(s.id)])), tui.getAllSessionCosts(), new Map(sessions.map((s) => [s.id, tui.getSessionHealthHistory(s.id)])));
|
|
1067
|
+
writeFileSync(join(dir, `stats-${ts}.json`), formatStatsJson(statEntries, pkg ?? "dev", now), "utf-8");
|
|
1068
|
+
tui.log("system", `export-all: snapshot + stats saved to ~/.aoaoe/ (${sessions.length} sessions)`);
|
|
1069
|
+
}
|
|
1070
|
+
catch (err) {
|
|
1071
|
+
tui.log("error", `export-all failed: ${err}`);
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1026
1074
|
// wire /budget cost alerts
|
|
1027
1075
|
input.onBudget((target, budgetUSD) => {
|
|
1028
1076
|
if (budgetUSD === null) {
|
|
@@ -1062,6 +1110,84 @@ async function main() {
|
|
|
1062
1110
|
}
|
|
1063
1111
|
tui.log("system", `${action}-all: sent to ${count} session${count !== 1 ? "s" : ""}`);
|
|
1064
1112
|
});
|
|
1113
|
+
// wire /health-trend
|
|
1114
|
+
input.onHealthTrend((target, height) => {
|
|
1115
|
+
const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
|
|
1116
|
+
const sessions = tui.getSessions();
|
|
1117
|
+
const session = num !== undefined
|
|
1118
|
+
? sessions[num - 1]
|
|
1119
|
+
: sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
|
|
1120
|
+
if (!session) {
|
|
1121
|
+
tui.log("system", `session not found: ${target}`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
const hist = tui.getSessionHealthHistory(session.id);
|
|
1125
|
+
const lines = formatHealthTrendChart(hist, session.title, height);
|
|
1126
|
+
for (const l of lines)
|
|
1127
|
+
tui.log("system", l);
|
|
1128
|
+
});
|
|
1129
|
+
// wire /alert-mute
|
|
1130
|
+
input.onAlertMute((pattern) => {
|
|
1131
|
+
if (pattern === null) {
|
|
1132
|
+
// null = "clear" keyword
|
|
1133
|
+
tui.clearAlertMutePatterns();
|
|
1134
|
+
tui.log("system", "alert-mute: all patterns cleared");
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (!pattern) {
|
|
1138
|
+
// empty = list patterns
|
|
1139
|
+
const pats = tui.getAlertMutePatterns();
|
|
1140
|
+
if (pats.size === 0) {
|
|
1141
|
+
tui.log("system", "alert-mute: no patterns set — use /alert-mute <text> to suppress");
|
|
1142
|
+
}
|
|
1143
|
+
else {
|
|
1144
|
+
tui.log("system", `alert-mute: ${pats.size} pattern${pats.size !== 1 ? "s" : ""}:`);
|
|
1145
|
+
for (const p of pats)
|
|
1146
|
+
tui.log("system", ` "${p}"`);
|
|
1147
|
+
}
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
tui.addAlertMutePattern(pattern);
|
|
1151
|
+
tui.log("system", `alert-mute: added "${pattern}" — matching alerts hidden from /alert-log`);
|
|
1152
|
+
});
|
|
1153
|
+
// wire /budgets list
|
|
1154
|
+
input.onBudgetsList(() => {
|
|
1155
|
+
const global = tui.getGlobalBudget();
|
|
1156
|
+
const perSession = tui.getAllSessionBudgets();
|
|
1157
|
+
if (global === null && perSession.size === 0) {
|
|
1158
|
+
tui.log("system", "budgets: none set — use /budget $N (global) or /budget <N> $N (per-session)");
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (global !== null)
|
|
1162
|
+
tui.log("system", ` global: $${global.toFixed(2)}`);
|
|
1163
|
+
const sessions = tui.getSessions();
|
|
1164
|
+
for (const [id, budget] of perSession) {
|
|
1165
|
+
const s = sessions.find((s) => s.id === id);
|
|
1166
|
+
const label = s?.title ?? id.slice(0, 8);
|
|
1167
|
+
tui.log("system", ` ${label}: $${budget.toFixed(2)}`);
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
// wire /budget-status
|
|
1171
|
+
input.onBudgetStatus(() => {
|
|
1172
|
+
const sessions = tui.getSessions();
|
|
1173
|
+
const costs = tui.getAllSessionCosts();
|
|
1174
|
+
const global = tui.getGlobalBudget();
|
|
1175
|
+
const perSession = tui.getAllSessionBudgets();
|
|
1176
|
+
let shown = 0;
|
|
1177
|
+
for (const s of sessions) {
|
|
1178
|
+
const budget = perSession.get(s.id) ?? global;
|
|
1179
|
+
const costStr = costs.get(s.id);
|
|
1180
|
+
if (budget === null)
|
|
1181
|
+
continue;
|
|
1182
|
+
const over = isOverBudget(costStr, budget);
|
|
1183
|
+
const costLabel = costStr ?? "(no data)";
|
|
1184
|
+
const status = over ? `OVER ($${budget.toFixed(2)} budget)` : `ok ($${budget.toFixed(2)} budget)`;
|
|
1185
|
+
tui.log("system", ` ${s.title}: ${costLabel} — ${status}`);
|
|
1186
|
+
shown++;
|
|
1187
|
+
}
|
|
1188
|
+
if (shown === 0)
|
|
1189
|
+
tui.log("system", "budget-status: no sessions with budgets configured");
|
|
1190
|
+
});
|
|
1065
1191
|
// wire /quiet-status
|
|
1066
1192
|
input.onQuietStatus(() => {
|
|
1067
1193
|
const { active, message } = formatQuietStatus(tui.getQuietHours());
|
package/dist/input.d.ts
CHANGED
|
@@ -58,6 +58,13 @@ export type QuietStatusHandler = () => void;
|
|
|
58
58
|
export type AlertLogHandler = (count: number) => void;
|
|
59
59
|
export type BudgetHandler = (target: string | null, budgetUSD: number | null) => void;
|
|
60
60
|
export type BulkControlHandler = (action: "pause" | "resume") => void;
|
|
61
|
+
export type HealthTrendHandler = (target: string, height: number) => void;
|
|
62
|
+
export type AlertMuteHandler = (pattern: string | null) => void;
|
|
63
|
+
export type BudgetsListHandler = () => void;
|
|
64
|
+
export type BudgetStatusHandler = () => void;
|
|
65
|
+
export type FlapLogHandler = () => void;
|
|
66
|
+
export type DrainHandler = (target: string, drain: boolean) => void;
|
|
67
|
+
export type ExportAllHandler = () => void;
|
|
61
68
|
export interface MouseEvent {
|
|
62
69
|
button: number;
|
|
63
70
|
col: number;
|
|
@@ -138,6 +145,13 @@ export declare class InputReader {
|
|
|
138
145
|
private alertLogHandler;
|
|
139
146
|
private budgetHandler;
|
|
140
147
|
private bulkControlHandler;
|
|
148
|
+
private healthTrendHandler;
|
|
149
|
+
private alertMuteHandler;
|
|
150
|
+
private budgetsListHandler;
|
|
151
|
+
private budgetStatusHandler;
|
|
152
|
+
private flapLogHandler;
|
|
153
|
+
private drainHandler;
|
|
154
|
+
private exportAllHandler;
|
|
141
155
|
private aliases;
|
|
142
156
|
private mouseDataListener;
|
|
143
157
|
onScroll(handler: (dir: ScrollDirection) => void): void;
|
|
@@ -201,6 +215,13 @@ export declare class InputReader {
|
|
|
201
215
|
onAlertLog(handler: AlertLogHandler): void;
|
|
202
216
|
onBudget(handler: BudgetHandler): void;
|
|
203
217
|
onBulkControl(handler: BulkControlHandler): void;
|
|
218
|
+
onHealthTrend(handler: HealthTrendHandler): void;
|
|
219
|
+
onAlertMute(handler: AlertMuteHandler): void;
|
|
220
|
+
onBudgetsList(handler: BudgetsListHandler): void;
|
|
221
|
+
onBudgetStatus(handler: BudgetStatusHandler): void;
|
|
222
|
+
onFlapLog(handler: FlapLogHandler): void;
|
|
223
|
+
onDrain(handler: DrainHandler): void;
|
|
224
|
+
onExportAll(handler: ExportAllHandler): void;
|
|
204
225
|
/** Set aliases from persisted prefs. */
|
|
205
226
|
setAliases(aliases: Record<string, string>): void;
|
|
206
227
|
/** Get current aliases as a plain object. */
|
package/dist/input.js
CHANGED
|
@@ -91,6 +91,13 @@ export class InputReader {
|
|
|
91
91
|
alertLogHandler = null;
|
|
92
92
|
budgetHandler = null;
|
|
93
93
|
bulkControlHandler = null;
|
|
94
|
+
healthTrendHandler = null;
|
|
95
|
+
alertMuteHandler = null;
|
|
96
|
+
budgetsListHandler = null;
|
|
97
|
+
budgetStatusHandler = null;
|
|
98
|
+
flapLogHandler = null;
|
|
99
|
+
drainHandler = null;
|
|
100
|
+
exportAllHandler = null;
|
|
94
101
|
aliases = new Map(); // /shortcut → /full command
|
|
95
102
|
mouseDataListener = null;
|
|
96
103
|
// register a callback for scroll key events (PgUp/PgDn/Home/End)
|
|
@@ -307,6 +314,13 @@ export class InputReader {
|
|
|
307
314
|
onAlertLog(handler) { this.alertLogHandler = handler; }
|
|
308
315
|
onBudget(handler) { this.budgetHandler = handler; }
|
|
309
316
|
onBulkControl(handler) { this.bulkControlHandler = handler; }
|
|
317
|
+
onHealthTrend(handler) { this.healthTrendHandler = handler; }
|
|
318
|
+
onAlertMute(handler) { this.alertMuteHandler = handler; }
|
|
319
|
+
onBudgetsList(handler) { this.budgetsListHandler = handler; }
|
|
320
|
+
onBudgetStatus(handler) { this.budgetStatusHandler = handler; }
|
|
321
|
+
onFlapLog(handler) { this.flapLogHandler = handler; }
|
|
322
|
+
onDrain(handler) { this.drainHandler = handler; }
|
|
323
|
+
onExportAll(handler) { this.exportAllHandler = handler; }
|
|
310
324
|
/** Set aliases from persisted prefs. */
|
|
311
325
|
setAliases(aliases) {
|
|
312
326
|
this.aliases.clear();
|
|
@@ -572,8 +586,16 @@ ${BOLD}navigation:${RESET}
|
|
|
572
586
|
/budget [N] [$] set cost budget: /budget 1 2.50 (session), /budget 2.50 (global), /budget clear
|
|
573
587
|
/pause-all send interrupt to all sessions
|
|
574
588
|
/resume-all send resume to all sessions
|
|
575
|
-
|
|
576
|
-
|
|
589
|
+
/alert-log [N] show last N auto-generated alerts (burn-rate/watchdog/ceiling; default 20)
|
|
590
|
+
/alert-mute [pat] suppress alerts containing pattern; no arg = list; clear = remove all
|
|
591
|
+
/health-trend N show ASCII health score chart for session N [height]
|
|
592
|
+
/budgets list all active cost budgets
|
|
593
|
+
/budget-status show which sessions are over or under budget
|
|
594
|
+
/flap-log show sessions recently flagged as flapping
|
|
595
|
+
/drain N mark session N as draining (supervisor will skip it)
|
|
596
|
+
/undrain N remove drain mark from session N
|
|
597
|
+
/export-all bulk export snapshot + stats JSON for all sessions
|
|
598
|
+
/history-stats show aggregate statistics from persisted activity history
|
|
577
599
|
/cost-summary show total estimated spend across all sessions
|
|
578
600
|
/session-report N generate full markdown report for a session → ~/.aoaoe/report-<name>-<ts>.md
|
|
579
601
|
/clip [N] copy last N activity entries to clipboard (default 20)
|
|
@@ -1168,6 +1190,80 @@ ${BOLD}other:${RESET}
|
|
|
1168
1190
|
else
|
|
1169
1191
|
console.error(`${DIM}resume-all not available${RESET}`);
|
|
1170
1192
|
break;
|
|
1193
|
+
case "/health-trend": {
|
|
1194
|
+
const htArgs = line.slice("/health-trend".length).trim().split(/\s+/).filter(Boolean);
|
|
1195
|
+
const htTarget = htArgs[0] ?? "";
|
|
1196
|
+
const htHeight = htArgs[1] ? parseInt(htArgs[1], 10) : 6;
|
|
1197
|
+
if (!htTarget) {
|
|
1198
|
+
console.error(`${DIM}usage: /health-trend <N|name> [height]${RESET}`);
|
|
1199
|
+
break;
|
|
1200
|
+
}
|
|
1201
|
+
if (this.healthTrendHandler)
|
|
1202
|
+
this.healthTrendHandler(htTarget, isNaN(htHeight) || htHeight < 2 ? 6 : Math.min(htHeight, 20));
|
|
1203
|
+
else
|
|
1204
|
+
console.error(`${DIM}health-trend not available${RESET}`);
|
|
1205
|
+
break;
|
|
1206
|
+
}
|
|
1207
|
+
case "/alert-mute": {
|
|
1208
|
+
const amArg = line.slice("/alert-mute".length).trim();
|
|
1209
|
+
if (this.alertMuteHandler) {
|
|
1210
|
+
if (amArg.toLowerCase() === "clear")
|
|
1211
|
+
this.alertMuteHandler(null);
|
|
1212
|
+
else
|
|
1213
|
+
this.alertMuteHandler(amArg || null);
|
|
1214
|
+
}
|
|
1215
|
+
else
|
|
1216
|
+
console.error(`${DIM}alert-mute not available${RESET}`);
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
case "/budgets":
|
|
1220
|
+
if (this.budgetsListHandler)
|
|
1221
|
+
this.budgetsListHandler();
|
|
1222
|
+
else
|
|
1223
|
+
console.error(`${DIM}budgets not available${RESET}`);
|
|
1224
|
+
break;
|
|
1225
|
+
case "/flap-log":
|
|
1226
|
+
if (this.flapLogHandler)
|
|
1227
|
+
this.flapLogHandler();
|
|
1228
|
+
else
|
|
1229
|
+
console.error(`${DIM}flap-log not available${RESET}`);
|
|
1230
|
+
break;
|
|
1231
|
+
case "/drain": {
|
|
1232
|
+
const drainArg = line.slice("/drain".length).trim();
|
|
1233
|
+
if (!drainArg) {
|
|
1234
|
+
console.error(`${DIM}usage: /drain <N|name>${RESET}`);
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
if (this.drainHandler)
|
|
1238
|
+
this.drainHandler(drainArg, true);
|
|
1239
|
+
else
|
|
1240
|
+
console.error(`${DIM}drain not available${RESET}`);
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
case "/undrain": {
|
|
1244
|
+
const undrainArg = line.slice("/undrain".length).trim();
|
|
1245
|
+
if (!undrainArg) {
|
|
1246
|
+
console.error(`${DIM}usage: /undrain <N|name>${RESET}`);
|
|
1247
|
+
break;
|
|
1248
|
+
}
|
|
1249
|
+
if (this.drainHandler)
|
|
1250
|
+
this.drainHandler(undrainArg, false);
|
|
1251
|
+
else
|
|
1252
|
+
console.error(`${DIM}undrain not available${RESET}`);
|
|
1253
|
+
break;
|
|
1254
|
+
}
|
|
1255
|
+
case "/export-all":
|
|
1256
|
+
if (this.exportAllHandler)
|
|
1257
|
+
this.exportAllHandler();
|
|
1258
|
+
else
|
|
1259
|
+
console.error(`${DIM}export-all not available${RESET}`);
|
|
1260
|
+
break;
|
|
1261
|
+
case "/budget-status":
|
|
1262
|
+
if (this.budgetStatusHandler)
|
|
1263
|
+
this.budgetStatusHandler();
|
|
1264
|
+
else
|
|
1265
|
+
console.error(`${DIM}budget-status not available${RESET}`);
|
|
1266
|
+
break;
|
|
1171
1267
|
case "/quiet-status":
|
|
1172
1268
|
if (this.quietStatusHandler)
|
|
1173
1269
|
this.quietStatusHandler();
|
package/dist/tui.d.ts
CHANGED
|
@@ -137,6 +137,36 @@ export interface HealthSnapshot {
|
|
|
137
137
|
* Each bucket covers 1/5 of the history window. Color: LIME/AMBER/ROSE by value.
|
|
138
138
|
*/
|
|
139
139
|
export declare function formatHealthSparkline(history: readonly HealthSnapshot[], now?: number): string;
|
|
140
|
+
/**
|
|
141
|
+
* Format health score history as a multi-line ASCII bar chart.
|
|
142
|
+
* Returns an array of lines suitable for logging to the activity area.
|
|
143
|
+
* Each column is one snapshot, ordered oldest→newest.
|
|
144
|
+
* Bar height 0–8 rows. Color: LIME/AMBER/ROSE.
|
|
145
|
+
*/
|
|
146
|
+
export declare function formatHealthTrendChart(history: readonly HealthSnapshot[], title: string, height?: number): string[];
|
|
147
|
+
/** Status change entry for flap detection. */
|
|
148
|
+
export interface StatusChange {
|
|
149
|
+
status: string;
|
|
150
|
+
ts: number;
|
|
151
|
+
}
|
|
152
|
+
/** Max status change entries stored per session. */
|
|
153
|
+
export declare const MAX_STATUS_HISTORY = 30;
|
|
154
|
+
/** Flap detection window: check for oscillation in the last N minutes. */
|
|
155
|
+
export declare const FLAP_WINDOW_MS: number;
|
|
156
|
+
/** Min status changes in window to be considered flapping. */
|
|
157
|
+
export declare const FLAP_THRESHOLD = 5;
|
|
158
|
+
/**
|
|
159
|
+
* Detect if a session is "flapping" — rapidly oscillating between statuses.
|
|
160
|
+
* Returns true when there are >= FLAP_THRESHOLD status changes in FLAP_WINDOW_MS.
|
|
161
|
+
*/
|
|
162
|
+
export declare function isFlapping(changes: readonly StatusChange[], now?: number, windowMs?: number, threshold?: number): boolean;
|
|
163
|
+
/** Drain icon shown in session cards for draining sessions. */
|
|
164
|
+
export declare const DRAIN_ICON = "\u21E3";
|
|
165
|
+
/**
|
|
166
|
+
* Check if an alert text matches any suppressed pattern (case-insensitive substring).
|
|
167
|
+
* Returns true when the alert should be hidden.
|
|
168
|
+
*/
|
|
169
|
+
export declare function isAlertMuted(text: string, patterns: ReadonlySet<string>): boolean;
|
|
140
170
|
/**
|
|
141
171
|
* Parse a cost string like "$3.42" → 3.42, or null if unparseable.
|
|
142
172
|
*/
|
|
@@ -415,6 +445,12 @@ export declare class TUI {
|
|
|
415
445
|
private sessionBudgets;
|
|
416
446
|
private globalBudget;
|
|
417
447
|
private budgetAlerted;
|
|
448
|
+
private sessionStatusHistory;
|
|
449
|
+
private prevSessionStatus;
|
|
450
|
+
private flapAlerted;
|
|
451
|
+
private alertMutePatterns;
|
|
452
|
+
private drainingIds;
|
|
453
|
+
private flapLog;
|
|
418
454
|
private viewMode;
|
|
419
455
|
private drilldownSessionId;
|
|
420
456
|
private sessionOutputs;
|
|
@@ -558,8 +594,35 @@ export declare class TUI {
|
|
|
558
594
|
getAllSessionBudgets(): ReadonlyMap<string, number>;
|
|
559
595
|
/** Return health history for a session (for sparkline). */
|
|
560
596
|
getSessionHealthHistory(id: string): readonly HealthSnapshot[];
|
|
561
|
-
/**
|
|
562
|
-
|
|
597
|
+
/** Mark a session as draining (by index, ID, or title). Returns true if found. */
|
|
598
|
+
drainSession(sessionIdOrIndex: string | number): boolean;
|
|
599
|
+
/** Remove drain mark from a session. Returns true if it was draining. */
|
|
600
|
+
undrainSession(sessionIdOrIndex: string | number): boolean;
|
|
601
|
+
/** Check if a session is draining. */
|
|
602
|
+
isDraining(id: string): boolean;
|
|
603
|
+
/** Return all draining session IDs (for reasoner prompt and display). */
|
|
604
|
+
getDrainingIds(): ReadonlySet<string>;
|
|
605
|
+
/** Return recent flap events (newest last). */
|
|
606
|
+
getFlapLog(): readonly {
|
|
607
|
+
sessionId: string;
|
|
608
|
+
title: string;
|
|
609
|
+
ts: number;
|
|
610
|
+
count: number;
|
|
611
|
+
}[];
|
|
612
|
+
/** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
|
|
613
|
+
getAlertLog(includeAll?: boolean): readonly ActivityEntry[];
|
|
614
|
+
/** Return status change history for a session. */
|
|
615
|
+
getSessionStatusHistory(id: string): readonly StatusChange[];
|
|
616
|
+
/** Check if a session is currently flapping. */
|
|
617
|
+
isSessionFlapping(id: string, now?: number): boolean;
|
|
618
|
+
/** Add a pattern to suppress from alert log display. */
|
|
619
|
+
addAlertMutePattern(pattern: string): void;
|
|
620
|
+
/** Remove a pattern from alert mute list. Returns true if it was present. */
|
|
621
|
+
removeAlertMutePattern(pattern: string): boolean;
|
|
622
|
+
/** Clear all alert mute patterns. */
|
|
623
|
+
clearAlertMutePatterns(): void;
|
|
624
|
+
/** Return all alert mute patterns (for display/persistence). */
|
|
625
|
+
getAlertMutePatterns(): ReadonlySet<string>;
|
|
563
626
|
/** Get the latest cost string for a session (or undefined). */
|
|
564
627
|
getSessionCost(id: string): string | undefined;
|
|
565
628
|
/** Return all session costs (for /stats). */
|
package/dist/tui.js
CHANGED
|
@@ -428,6 +428,77 @@ export function formatHealthSparkline(history, now) {
|
|
|
428
428
|
return `${color}${SPARK_BLOCKS[Math.min(SPARK_BLOCKS.length - 1, Math.floor(score / 100 * (SPARK_BLOCKS.length - 1)))]}${RESET}`;
|
|
429
429
|
}).join("");
|
|
430
430
|
}
|
|
431
|
+
// ── Health trend chart (pure, exported for testing) ───────────────────────────
|
|
432
|
+
/**
|
|
433
|
+
* Format health score history as a multi-line ASCII bar chart.
|
|
434
|
+
* Returns an array of lines suitable for logging to the activity area.
|
|
435
|
+
* Each column is one snapshot, ordered oldest→newest.
|
|
436
|
+
* Bar height 0–8 rows. Color: LIME/AMBER/ROSE.
|
|
437
|
+
*/
|
|
438
|
+
export function formatHealthTrendChart(history, title, height = 6) {
|
|
439
|
+
if (history.length === 0)
|
|
440
|
+
return [` ${title}: no health history`];
|
|
441
|
+
const MAX_COLS = 40;
|
|
442
|
+
const samples = history.slice(-MAX_COLS);
|
|
443
|
+
const lines = [];
|
|
444
|
+
// header
|
|
445
|
+
const minScore = Math.min(...samples.map((h) => h.score));
|
|
446
|
+
const maxScore = Math.max(...samples.map((h) => h.score));
|
|
447
|
+
lines.push(` ${DIM}${title}${RESET} health trend (${samples.length} samples, ${minScore}–${maxScore})`);
|
|
448
|
+
// chart rows (top = high score, bottom = low score)
|
|
449
|
+
for (let row = height - 1; row >= 0; row--) {
|
|
450
|
+
const threshold = Math.round(((row + 1) / height) * 100);
|
|
451
|
+
const prevThreshold = Math.round((row / height) * 100);
|
|
452
|
+
const yLabel = row === height - 1 ? "100" : row === 0 ? " 0" : ` `;
|
|
453
|
+
const bar = samples.map((h) => {
|
|
454
|
+
if (h.score >= threshold) {
|
|
455
|
+
const color = h.score >= HEALTH_GOOD ? LIME : h.score >= HEALTH_WARN ? AMBER : ROSE;
|
|
456
|
+
return `${color}█${RESET}`;
|
|
457
|
+
}
|
|
458
|
+
if (h.score >= prevThreshold) {
|
|
459
|
+
const color = h.score >= HEALTH_GOOD ? LIME : h.score >= HEALTH_WARN ? AMBER : ROSE;
|
|
460
|
+
return `${color}▄${RESET}`;
|
|
461
|
+
}
|
|
462
|
+
return `${DIM}·${RESET}`;
|
|
463
|
+
}).join("");
|
|
464
|
+
lines.push(` ${DIM}${yLabel}${RESET}│${bar}`);
|
|
465
|
+
}
|
|
466
|
+
lines.push(` └${"─".repeat(samples.length)}`);
|
|
467
|
+
return lines;
|
|
468
|
+
}
|
|
469
|
+
/** Max status change entries stored per session. */
|
|
470
|
+
export const MAX_STATUS_HISTORY = 30;
|
|
471
|
+
/** Flap detection window: check for oscillation in the last N minutes. */
|
|
472
|
+
export const FLAP_WINDOW_MS = 10 * 60_000;
|
|
473
|
+
/** Min status changes in window to be considered flapping. */
|
|
474
|
+
export const FLAP_THRESHOLD = 5;
|
|
475
|
+
/**
|
|
476
|
+
* Detect if a session is "flapping" — rapidly oscillating between statuses.
|
|
477
|
+
* Returns true when there are >= FLAP_THRESHOLD status changes in FLAP_WINDOW_MS.
|
|
478
|
+
*/
|
|
479
|
+
export function isFlapping(changes, now, windowMs = FLAP_WINDOW_MS, threshold = FLAP_THRESHOLD) {
|
|
480
|
+
const cutoff = (now ?? Date.now()) - windowMs;
|
|
481
|
+
const recent = changes.filter((c) => c.ts >= cutoff);
|
|
482
|
+
return recent.length >= threshold;
|
|
483
|
+
}
|
|
484
|
+
// ── Session drain mode helpers ────────────────────────────────────────────────
|
|
485
|
+
/** Drain icon shown in session cards for draining sessions. */
|
|
486
|
+
export const DRAIN_ICON = "⇣";
|
|
487
|
+
// ── Alert mute patterns (pure, exported for testing) ──────────────────────────
|
|
488
|
+
/**
|
|
489
|
+
* Check if an alert text matches any suppressed pattern (case-insensitive substring).
|
|
490
|
+
* Returns true when the alert should be hidden.
|
|
491
|
+
*/
|
|
492
|
+
export function isAlertMuted(text, patterns) {
|
|
493
|
+
if (patterns.size === 0)
|
|
494
|
+
return false;
|
|
495
|
+
const lower = text.toLowerCase();
|
|
496
|
+
for (const p of patterns) {
|
|
497
|
+
if (lower.includes(p.toLowerCase()))
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
431
502
|
// ── Cost summary (pure, exported for testing) ────────────────────────────────
|
|
432
503
|
/**
|
|
433
504
|
* Parse a cost string like "$3.42" → 3.42, or null if unparseable.
|
|
@@ -738,7 +809,8 @@ export const BUILTIN_COMMANDS = new Set([
|
|
|
738
809
|
"/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
|
|
739
810
|
"/mute-errors", "/prev-goal", "/tag", "/tags", "/tag-filter", "/find", "/reset-health", "/timeline", "/color", "/clear-history",
|
|
740
811
|
"/duplicate", "/color-all", "/quiet-hours", "/quiet-status", "/history-stats", "/cost-summary", "/session-report", "/alert-log",
|
|
741
|
-
"/budget", "/pause-all", "/resume-all",
|
|
812
|
+
"/budget", "/budgets", "/budget-status", "/pause-all", "/resume-all",
|
|
813
|
+
"/health-trend", "/alert-mute", "/flap-log", "/drain", "/undrain", "/export-all",
|
|
742
814
|
]);
|
|
743
815
|
/** Resolve a slash command through the alias map. Returns the expanded command or the original. */
|
|
744
816
|
export function resolveAlias(line, aliases) {
|
|
@@ -1015,6 +1087,12 @@ export class TUI {
|
|
|
1015
1087
|
sessionBudgets = new Map(); // session ID → USD budget
|
|
1016
1088
|
globalBudget = null; // global fallback budget in USD
|
|
1017
1089
|
budgetAlerted = new Map(); // session ID → epoch ms of last budget alert
|
|
1090
|
+
sessionStatusHistory = new Map(); // session ID → status change log
|
|
1091
|
+
prevSessionStatus = new Map(); // session ID → last known status (for change detection)
|
|
1092
|
+
flapAlerted = new Map(); // session ID → epoch ms of last flap alert
|
|
1093
|
+
alertMutePatterns = new Set(); // substrings to hide from /alert-log display
|
|
1094
|
+
drainingIds = new Set(); // session IDs marked as draining (skip by reasoner)
|
|
1095
|
+
flapLog = []; // recent flap events
|
|
1018
1096
|
// drill-down mode: show a single session's full output
|
|
1019
1097
|
viewMode = "overview";
|
|
1020
1098
|
drilldownSessionId = null;
|
|
@@ -1527,9 +1605,87 @@ export class TUI {
|
|
|
1527
1605
|
getSessionHealthHistory(id) {
|
|
1528
1606
|
return this.sessionHealthHistory.get(id) ?? [];
|
|
1529
1607
|
}
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1608
|
+
// ── Session drain mode ───────────────────────────────────────────────────
|
|
1609
|
+
/** Mark a session as draining (by index, ID, or title). Returns true if found. */
|
|
1610
|
+
drainSession(sessionIdOrIndex) {
|
|
1611
|
+
let sessionId;
|
|
1612
|
+
if (typeof sessionIdOrIndex === "number") {
|
|
1613
|
+
sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
const needle = sessionIdOrIndex.toLowerCase();
|
|
1617
|
+
const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
|
|
1618
|
+
sessionId = match?.id;
|
|
1619
|
+
}
|
|
1620
|
+
if (!sessionId)
|
|
1621
|
+
return false;
|
|
1622
|
+
this.drainingIds.add(sessionId);
|
|
1623
|
+
if (this.active)
|
|
1624
|
+
this.paintSessions();
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
/** Remove drain mark from a session. Returns true if it was draining. */
|
|
1628
|
+
undrainSession(sessionIdOrIndex) {
|
|
1629
|
+
let sessionId;
|
|
1630
|
+
if (typeof sessionIdOrIndex === "number") {
|
|
1631
|
+
sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
const needle = sessionIdOrIndex.toLowerCase();
|
|
1635
|
+
const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
|
|
1636
|
+
sessionId = match?.id;
|
|
1637
|
+
}
|
|
1638
|
+
if (!sessionId)
|
|
1639
|
+
return false;
|
|
1640
|
+
const had = this.drainingIds.delete(sessionId);
|
|
1641
|
+
if (had && this.active)
|
|
1642
|
+
this.paintSessions();
|
|
1643
|
+
return had;
|
|
1644
|
+
}
|
|
1645
|
+
/** Check if a session is draining. */
|
|
1646
|
+
isDraining(id) {
|
|
1647
|
+
return this.drainingIds.has(id);
|
|
1648
|
+
}
|
|
1649
|
+
/** Return all draining session IDs (for reasoner prompt and display). */
|
|
1650
|
+
getDrainingIds() {
|
|
1651
|
+
return this.drainingIds;
|
|
1652
|
+
}
|
|
1653
|
+
// ── Flap log ─────────────────────────────────────────────────────────────
|
|
1654
|
+
/** Return recent flap events (newest last). */
|
|
1655
|
+
getFlapLog() {
|
|
1656
|
+
return this.flapLog;
|
|
1657
|
+
}
|
|
1658
|
+
/** Return all alert log entries (last 100 "status" tag entries), filtered by mute patterns. */
|
|
1659
|
+
getAlertLog(includeAll = false) {
|
|
1660
|
+
if (includeAll || this.alertMutePatterns.size === 0)
|
|
1661
|
+
return this.alertLog;
|
|
1662
|
+
return this.alertLog.filter((e) => !isAlertMuted(e.text, this.alertMutePatterns));
|
|
1663
|
+
}
|
|
1664
|
+
/** Return status change history for a session. */
|
|
1665
|
+
getSessionStatusHistory(id) {
|
|
1666
|
+
return this.sessionStatusHistory.get(id) ?? [];
|
|
1667
|
+
}
|
|
1668
|
+
/** Check if a session is currently flapping. */
|
|
1669
|
+
isSessionFlapping(id, now) {
|
|
1670
|
+
const hist = this.sessionStatusHistory.get(id);
|
|
1671
|
+
return hist ? isFlapping(hist, now) : false;
|
|
1672
|
+
}
|
|
1673
|
+
// ── Alert mute patterns ──────────────────────────────────────────────────
|
|
1674
|
+
/** Add a pattern to suppress from alert log display. */
|
|
1675
|
+
addAlertMutePattern(pattern) {
|
|
1676
|
+
this.alertMutePatterns.add(pattern.toLowerCase().trim());
|
|
1677
|
+
}
|
|
1678
|
+
/** Remove a pattern from alert mute list. Returns true if it was present. */
|
|
1679
|
+
removeAlertMutePattern(pattern) {
|
|
1680
|
+
return this.alertMutePatterns.delete(pattern.toLowerCase().trim());
|
|
1681
|
+
}
|
|
1682
|
+
/** Clear all alert mute patterns. */
|
|
1683
|
+
clearAlertMutePatterns() {
|
|
1684
|
+
this.alertMutePatterns.clear();
|
|
1685
|
+
}
|
|
1686
|
+
/** Return all alert mute patterns (for display/persistence). */
|
|
1687
|
+
getAlertMutePatterns() {
|
|
1688
|
+
return this.alertMutePatterns;
|
|
1533
1689
|
}
|
|
1534
1690
|
/** Get the latest cost string for a session (or undefined). */
|
|
1535
1691
|
getSessionCost(id) {
|
|
@@ -1849,6 +2005,28 @@ export class TUI {
|
|
|
1849
2005
|
for (const s of opts.sessions) {
|
|
1850
2006
|
if (!this.sessionFirstSeen.has(s.id))
|
|
1851
2007
|
this.sessionFirstSeen.set(s.id, now);
|
|
2008
|
+
// track status changes for flap detection
|
|
2009
|
+
const prevStatus = this.prevSessionStatus.get(s.id);
|
|
2010
|
+
if (prevStatus !== undefined && prevStatus !== s.status) {
|
|
2011
|
+
const statusHist = this.sessionStatusHistory.get(s.id) ?? [];
|
|
2012
|
+
statusHist.push({ status: s.status, ts: now });
|
|
2013
|
+
if (statusHist.length > MAX_STATUS_HISTORY)
|
|
2014
|
+
statusHist.shift();
|
|
2015
|
+
this.sessionStatusHistory.set(s.id, statusHist);
|
|
2016
|
+
// check for flapping
|
|
2017
|
+
if (isFlapping(statusHist, now) && !quietNow) {
|
|
2018
|
+
const lastFlapAlert = this.flapAlerted.get(s.id) ?? 0;
|
|
2019
|
+
if (now - lastFlapAlert >= 5 * 60_000) {
|
|
2020
|
+
this.flapAlerted.set(s.id, now);
|
|
2021
|
+
const flapCount = statusHist.filter((c) => c.ts >= now - FLAP_WINDOW_MS).length;
|
|
2022
|
+
this.flapLog.push({ sessionId: s.id, title: s.title, ts: now, count: flapCount });
|
|
2023
|
+
if (this.flapLog.length > 50)
|
|
2024
|
+
this.flapLog.shift();
|
|
2025
|
+
this.log("status", `flap: ${s.title} is oscillating rapidly (${flapCount} status changes in ${Math.round(FLAP_WINDOW_MS / 60_000)}m)`, s.id);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
this.prevSessionStatus.set(s.id, s.status);
|
|
1852
2030
|
const prev = this.prevLastActivity.get(s.id);
|
|
1853
2031
|
if (s.lastActivity !== undefined && s.lastActivity !== prev) {
|
|
1854
2032
|
this.lastChangeAt.set(s.id, now);
|
|
@@ -2444,6 +2622,8 @@ export class TUI {
|
|
|
2444
2622
|
const colorName = this.sessionColors.get(s.id);
|
|
2445
2623
|
const colorDot = colorName ? formatColorDot(colorName) : "";
|
|
2446
2624
|
const colorDotWidth = colorName ? 2 : 0; // dot + space
|
|
2625
|
+
const draining = this.drainingIds.has(s.id);
|
|
2626
|
+
const drainIcon = draining ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
|
|
2447
2627
|
const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
|
|
2448
2628
|
const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
|
|
2449
2629
|
const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
|
|
@@ -2453,10 +2633,10 @@ export class TUI {
|
|
|
2453
2633
|
const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
|
|
2454
2634
|
const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
|
|
2455
2635
|
const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
|
|
2456
|
-
const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth;
|
|
2636
|
+
const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth + colorDotWidth + (draining ? 2 : 0);
|
|
2457
2637
|
const cardWidth = innerWidth - 1 - iconsWidth;
|
|
2458
2638
|
const cardAge = s.createdAt ? formatSessionAge(s.createdAt, nowMs) : undefined;
|
|
2459
|
-
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined)}`;
|
|
2639
|
+
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${colorDot}${drainIcon}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName, cardAge || undefined)}`;
|
|
2460
2640
|
const padded = padBoxLineHover(line, this.cols, isHovered);
|
|
2461
2641
|
process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
|
|
2462
2642
|
}
|
|
@@ -2524,10 +2704,12 @@ export class TUI {
|
|
|
2524
2704
|
const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
|
|
2525
2705
|
const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
|
|
2526
2706
|
const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
|
|
2527
|
-
const
|
|
2707
|
+
const draining2 = this.drainingIds.has(s.id);
|
|
2708
|
+
const drainIcon2 = draining2 ? `${DIM}${DRAIN_ICON}${RESET} ` : "";
|
|
2709
|
+
const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth2 + colorDotWidth2 + (draining2 ? 2 : 0);
|
|
2528
2710
|
const cardWidth = innerWidth - 1 - iconsWidth;
|
|
2529
2711
|
const cardAge2 = s.createdAt ? formatSessionAge(s.createdAt, nowMs2) : undefined;
|
|
2530
|
-
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined)}`;
|
|
2712
|
+
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge2}${colorDot2}${drainIcon2}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge2 || undefined, displayName2, cardAge2 || undefined)}`;
|
|
2531
2713
|
const padded = padBoxLineHover(line, this.cols, isHovered);
|
|
2532
2714
|
process.stderr.write(SAVE_CURSOR + moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded + RESTORE_CURSOR);
|
|
2533
2715
|
}
|