aoaoe 0.116.0 → 0.126.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 +126 -3
- package/dist/input.d.ts +24 -0
- package/dist/input.js +153 -1
- package/dist/tui-history.d.ts +6 -0
- package/dist/tui-history.js +47 -0
- package/dist/tui.d.ts +100 -1
- package/dist/tui.js +326 -37
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -16,11 +16,11 @@ 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 } 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 } 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";
|
|
23
|
-
import { loadTuiHistory } from "./tui-history.js";
|
|
23
|
+
import { loadTuiHistory, searchHistory } from "./tui-history.js";
|
|
24
24
|
import { ConfigWatcher, formatConfigChange } from "./config-watcher.js";
|
|
25
25
|
import { parseActionLogEntries, parseActivityEntries, mergeTimeline, filterByAge, parseDuration, formatTimelineJson, formatTimelineMarkdown } from "./export.js";
|
|
26
26
|
import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
|
|
@@ -190,17 +190,22 @@ async function main() {
|
|
|
190
190
|
tui.restoreGroups(prefs.sessionGroups);
|
|
191
191
|
if (prefs.sessionAliases)
|
|
192
192
|
tui.restoreSessionAliases(prefs.sessionAliases);
|
|
193
|
+
if (prefs.sessionTags)
|
|
194
|
+
tui.restoreSessionTags(prefs.sessionTags);
|
|
193
195
|
}
|
|
194
196
|
const persistPrefs = () => {
|
|
195
197
|
if (!tui)
|
|
196
198
|
return;
|
|
197
|
-
// persist session groups
|
|
199
|
+
// persist session groups, aliases, and multi-tags
|
|
198
200
|
const groupsObj = {};
|
|
199
201
|
for (const [id, g] of tui.getAllGroups())
|
|
200
202
|
groupsObj[id] = g;
|
|
201
203
|
const aliasesObj = {};
|
|
202
204
|
for (const [id, name] of tui.getAllSessionAliases())
|
|
203
205
|
aliasesObj[id] = name;
|
|
206
|
+
const sTagsObj = {};
|
|
207
|
+
for (const [id, tset] of tui.getAllSessionTags())
|
|
208
|
+
sTagsObj[id] = [...tset];
|
|
204
209
|
saveTuiPrefs({
|
|
205
210
|
sortMode: tui.getSortMode(),
|
|
206
211
|
compact: tui.isCompact(),
|
|
@@ -211,6 +216,7 @@ async function main() {
|
|
|
211
216
|
aliases: input.getAliases(),
|
|
212
217
|
sessionGroups: groupsObj,
|
|
213
218
|
sessionAliases: aliasesObj,
|
|
219
|
+
sessionTags: sTagsObj,
|
|
214
220
|
});
|
|
215
221
|
};
|
|
216
222
|
if (!useTui) {
|
|
@@ -757,6 +763,123 @@ async function main() {
|
|
|
757
763
|
if (!any)
|
|
758
764
|
tui.log("system", ` threshold: ${CONTEXT_BURN_THRESHOLD.toLocaleString()} tokens/min`);
|
|
759
765
|
});
|
|
766
|
+
// wire /mute-errors toggle
|
|
767
|
+
input.onMuteErrors(() => {
|
|
768
|
+
const muted = tui.toggleMuteErrors();
|
|
769
|
+
tui.log("system", `mute-errors: ${muted ? "on — error entries hidden from activity log" : "off — all entries visible"}`);
|
|
770
|
+
});
|
|
771
|
+
// wire /prev-goal
|
|
772
|
+
input.onPrevGoal((target, nBack) => {
|
|
773
|
+
const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
|
|
774
|
+
const sessions = tui.getSessions();
|
|
775
|
+
const session = num !== undefined
|
|
776
|
+
? sessions[num - 1]
|
|
777
|
+
: sessions.find((s) => s.title.toLowerCase() === target.toLowerCase() || s.id.startsWith(target));
|
|
778
|
+
if (!session) {
|
|
779
|
+
tui.log("system", `session not found: ${target}`);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const goal = tui.getPreviousGoal(session.id, nBack);
|
|
783
|
+
if (!goal) {
|
|
784
|
+
tui.log("system", `no goal history for ${session.title} (${nBack} back)`);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// queue as a task update for that session
|
|
788
|
+
input.inject(`__CMD_QUICKTASK__${goal}`);
|
|
789
|
+
tui.log("system", `prev-goal restored for ${session.title}: "${goal}"`);
|
|
790
|
+
});
|
|
791
|
+
// wire /tag set session tags
|
|
792
|
+
input.onTag((target, tags) => {
|
|
793
|
+
for (const t of tags) {
|
|
794
|
+
const err = validateSessionTag(t);
|
|
795
|
+
if (err) {
|
|
796
|
+
tui.log("system", `invalid tag "${t}": ${err}`);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
const num = /^\d+$/.test(target) ? parseInt(target, 10) : undefined;
|
|
801
|
+
const ok = tui.setSessionTags(num ?? target, tags);
|
|
802
|
+
if (ok) {
|
|
803
|
+
if (tags.length > 0) {
|
|
804
|
+
tui.log("system", `tags set for ${target}: ${tags.join(", ")}`);
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
tui.log("system", `tags cleared for ${target}`);
|
|
808
|
+
}
|
|
809
|
+
persistPrefs();
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
tui.log("system", `session not found: ${target}`);
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
// wire /tags list
|
|
816
|
+
input.onTagsList(() => {
|
|
817
|
+
const allTags = tui.getAllSessionTags();
|
|
818
|
+
if (allTags.size === 0) {
|
|
819
|
+
tui.log("system", "no tags — use /tag <N|name> <tag1,tag2> to assign");
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const sessions = tui.getSessions();
|
|
823
|
+
for (const [id, tset] of allTags) {
|
|
824
|
+
const s = sessions.find((s) => s.id === id);
|
|
825
|
+
const label = s?.title ?? id.slice(0, 8);
|
|
826
|
+
tui.log("system", ` ${label}: ${[...tset].sort().join(", ")}`);
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
// wire /pin-all-errors
|
|
830
|
+
input.onPinAllErrors(() => {
|
|
831
|
+
const count = tui.pinAllErrors();
|
|
832
|
+
if (count === 0) {
|
|
833
|
+
tui.log("system", "pin-all-errors: no error sessions to pin");
|
|
834
|
+
}
|
|
835
|
+
else {
|
|
836
|
+
tui.log("system", `pin-all-errors: pinned ${count} session${count !== 1 ? "s" : ""}`);
|
|
837
|
+
persistPrefs();
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
// wire /export-stats
|
|
841
|
+
input.onExportStats(() => {
|
|
842
|
+
const sessions = tui.getSessions();
|
|
843
|
+
const now = Date.now();
|
|
844
|
+
const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
|
|
845
|
+
const ts = new Date(now).toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
846
|
+
const dir = join(homedir(), ".aoaoe");
|
|
847
|
+
const path = join(dir, `stats-${ts}.json`);
|
|
848
|
+
try {
|
|
849
|
+
mkdirSync(dir, { recursive: true });
|
|
850
|
+
writeFileSync(path, formatStatsJson(entries, pkg ?? "dev", now), "utf-8");
|
|
851
|
+
tui.log("system", `stats exported: ~/.aoaoe/stats-${ts}.json (${entries.length} sessions)`);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
tui.log("error", `export-stats failed: ${err}`);
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
// wire /recall — search persisted history
|
|
858
|
+
input.onRecall((keyword, maxResults) => {
|
|
859
|
+
const matches = searchHistory(keyword, maxResults);
|
|
860
|
+
if (matches.length === 0) {
|
|
861
|
+
tui.log("system", `recall: no matches for "${keyword}" in history`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
tui.log("system", `recall: ${matches.length} match${matches.length !== 1 ? "es" : ""} for "${keyword}":`);
|
|
865
|
+
for (const e of matches) {
|
|
866
|
+
tui.log("system", ` ${e.time} ${e.tag} ${e.text}`);
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
// wire /stats per-session summary
|
|
870
|
+
input.onStats(() => {
|
|
871
|
+
const sessions = tui.getSessions();
|
|
872
|
+
if (sessions.length === 0) {
|
|
873
|
+
tui.log("system", "no sessions — no stats available");
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
const now = Date.now();
|
|
877
|
+
const entries = buildSessionStats(sessions, tui.getSessionErrorCounts(), tui.getAllBurnRates(now), tui.getAllFirstSeen(), tui.getAllLastChangeAt(), tui.getAllHealthScores(now), tui.getAllSessionAliases(), now);
|
|
878
|
+
tui.log("system", `/stats — ${entries.length} session${entries.length !== 1 ? "s" : ""}:`);
|
|
879
|
+
for (const line of formatSessionStatsLines(entries)) {
|
|
880
|
+
tui.log("system", line);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
760
883
|
// wire /copy session pane output to clipboard
|
|
761
884
|
input.onCopySession((target) => {
|
|
762
885
|
// resolve target: null = current drill-down session
|
package/dist/input.d.ts
CHANGED
|
@@ -34,6 +34,14 @@ export type TopHandler = (mode: string) => void;
|
|
|
34
34
|
export type CeilingHandler = () => void;
|
|
35
35
|
export type RenameHandler = (target: string, name: string) => void;
|
|
36
36
|
export type CopySessionHandler = (target: string | null) => void;
|
|
37
|
+
export type StatsHandler = () => void;
|
|
38
|
+
export type RecallHandler = (keyword: string, maxResults: number) => void;
|
|
39
|
+
export type PinAllErrorsHandler = () => void;
|
|
40
|
+
export type ExportStatsHandler = () => void;
|
|
41
|
+
export type MuteErrorsHandler = () => void;
|
|
42
|
+
export type PrevGoalHandler = (target: string, nBack: number) => void;
|
|
43
|
+
export type TagHandler = (target: string, tags: string[]) => void;
|
|
44
|
+
export type TagsListHandler = () => void;
|
|
37
45
|
export interface MouseEvent {
|
|
38
46
|
button: number;
|
|
39
47
|
col: number;
|
|
@@ -90,6 +98,14 @@ export declare class InputReader {
|
|
|
90
98
|
private ceilingHandler;
|
|
91
99
|
private renameHandler;
|
|
92
100
|
private copySessionHandler;
|
|
101
|
+
private statsHandler;
|
|
102
|
+
private recallHandler;
|
|
103
|
+
private pinAllErrorsHandler;
|
|
104
|
+
private exportStatsHandler;
|
|
105
|
+
private muteErrorsHandler;
|
|
106
|
+
private prevGoalHandler;
|
|
107
|
+
private tagHandler;
|
|
108
|
+
private tagsListHandler;
|
|
93
109
|
private aliases;
|
|
94
110
|
private mouseDataListener;
|
|
95
111
|
onScroll(handler: (dir: ScrollDirection) => void): void;
|
|
@@ -129,6 +145,14 @@ export declare class InputReader {
|
|
|
129
145
|
onCeiling(handler: CeilingHandler): void;
|
|
130
146
|
onRename(handler: RenameHandler): void;
|
|
131
147
|
onCopySession(handler: CopySessionHandler): void;
|
|
148
|
+
onStats(handler: StatsHandler): void;
|
|
149
|
+
onRecall(handler: RecallHandler): void;
|
|
150
|
+
onPinAllErrors(handler: PinAllErrorsHandler): void;
|
|
151
|
+
onExportStats(handler: ExportStatsHandler): void;
|
|
152
|
+
onMuteErrors(handler: MuteErrorsHandler): void;
|
|
153
|
+
onPrevGoal(handler: PrevGoalHandler): void;
|
|
154
|
+
onTag(handler: TagHandler): void;
|
|
155
|
+
onTagsList(handler: TagsListHandler): void;
|
|
132
156
|
/** Set aliases from persisted prefs. */
|
|
133
157
|
setAliases(aliases: Record<string, string>): void;
|
|
134
158
|
/** Get current aliases as a plain object. */
|
package/dist/input.js
CHANGED
|
@@ -67,6 +67,14 @@ export class InputReader {
|
|
|
67
67
|
ceilingHandler = null;
|
|
68
68
|
renameHandler = null;
|
|
69
69
|
copySessionHandler = null;
|
|
70
|
+
statsHandler = null;
|
|
71
|
+
recallHandler = null;
|
|
72
|
+
pinAllErrorsHandler = null;
|
|
73
|
+
exportStatsHandler = null;
|
|
74
|
+
muteErrorsHandler = null;
|
|
75
|
+
prevGoalHandler = null;
|
|
76
|
+
tagHandler = null;
|
|
77
|
+
tagsListHandler = null;
|
|
70
78
|
aliases = new Map(); // /shortcut → /full command
|
|
71
79
|
mouseDataListener = null;
|
|
72
80
|
// register a callback for scroll key events (PgUp/PgDn/Home/End)
|
|
@@ -217,6 +225,38 @@ export class InputReader {
|
|
|
217
225
|
onCopySession(handler) {
|
|
218
226
|
this.copySessionHandler = handler;
|
|
219
227
|
}
|
|
228
|
+
// register a callback for /stats — per-session stats summary
|
|
229
|
+
onStats(handler) {
|
|
230
|
+
this.statsHandler = handler;
|
|
231
|
+
}
|
|
232
|
+
// register a callback for /recall <keyword> [N] — search history
|
|
233
|
+
onRecall(handler) {
|
|
234
|
+
this.recallHandler = handler;
|
|
235
|
+
}
|
|
236
|
+
// register a callback for /pin-all-errors — pin all error sessions
|
|
237
|
+
onPinAllErrors(handler) {
|
|
238
|
+
this.pinAllErrorsHandler = handler;
|
|
239
|
+
}
|
|
240
|
+
// register a callback for /export-stats — export stats to JSON file
|
|
241
|
+
onExportStats(handler) {
|
|
242
|
+
this.exportStatsHandler = handler;
|
|
243
|
+
}
|
|
244
|
+
// register a callback for /mute-errors — toggle error-tag suppression
|
|
245
|
+
onMuteErrors(handler) {
|
|
246
|
+
this.muteErrorsHandler = handler;
|
|
247
|
+
}
|
|
248
|
+
// register a callback for /prev-goal <N|name> [nBack] — restore previous goal
|
|
249
|
+
onPrevGoal(handler) {
|
|
250
|
+
this.prevGoalHandler = handler;
|
|
251
|
+
}
|
|
252
|
+
// register a callback for /tag <N|name> [tag1,tag2] — set session tags
|
|
253
|
+
onTag(handler) {
|
|
254
|
+
this.tagHandler = handler;
|
|
255
|
+
}
|
|
256
|
+
// register a callback for /tags — list all session tags
|
|
257
|
+
onTagsList(handler) {
|
|
258
|
+
this.tagsListHandler = handler;
|
|
259
|
+
}
|
|
220
260
|
/** Set aliases from persisted prefs. */
|
|
221
261
|
setAliases(aliases) {
|
|
222
262
|
this.aliases.clear();
|
|
@@ -363,7 +403,13 @@ export class InputReader {
|
|
|
363
403
|
this.rl?.prompt();
|
|
364
404
|
return;
|
|
365
405
|
}
|
|
366
|
-
// quick-switch: bare digit 1-9
|
|
406
|
+
// quick-switch: bare digit 1-9, or g+N for sessions 1-99
|
|
407
|
+
const gSwitch = line.match(/^g([1-9]\d?)$/);
|
|
408
|
+
if (gSwitch && this.quickSwitchHandler) {
|
|
409
|
+
this.quickSwitchHandler(parseInt(gSwitch[1], 10));
|
|
410
|
+
this.rl?.prompt();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
367
413
|
if (/^[1-9]$/.test(line) && this.quickSwitchHandler) {
|
|
368
414
|
this.quickSwitchHandler(parseInt(line, 10));
|
|
369
415
|
this.rl?.prompt();
|
|
@@ -428,6 +474,7 @@ ${BOLD}controls:${RESET}
|
|
|
428
474
|
|
|
429
475
|
${BOLD}navigation:${RESET}
|
|
430
476
|
1-9 quick-switch: jump to session N (type digit + Enter)
|
|
477
|
+
g1-g99 quick-switch for sessions 10+ (e.g. g12 jumps to session 12)
|
|
431
478
|
/view [N|name] drill into a session's live output (default: 1)
|
|
432
479
|
/back return to overview from drill-down
|
|
433
480
|
/sort [mode] sort sessions: status, name, activity, default (or cycle)
|
|
@@ -454,6 +501,14 @@ ${BOLD}navigation:${RESET}
|
|
|
454
501
|
/ceiling show context token usage vs limit for all sessions
|
|
455
502
|
/rename N|name [display] set custom display name in TUI (no display = clear)
|
|
456
503
|
/copy [N|name] copy session's current pane output to clipboard (default: current drill-down)
|
|
504
|
+
/stats show per-session health, errors, burn rate, context %, uptime
|
|
505
|
+
/recall <keyword> search persisted activity history (last 7 days) for keyword
|
|
506
|
+
/pin-all-errors pin every session currently in error status
|
|
507
|
+
/export-stats export /stats output as JSON to ~/.aoaoe/stats-<ts>.json
|
|
508
|
+
/mute-errors toggle suppression of error/! action entries in activity log
|
|
509
|
+
/prev-goal N [n] restore Nth session's goal from history (n=1 most recent)
|
|
510
|
+
/tag N tag1,tag2 set freeform tags on a session (no tags = clear)
|
|
511
|
+
/tags list all session tags
|
|
457
512
|
/clip [N] copy last N activity entries to clipboard (default 20)
|
|
458
513
|
/diff N show activity since bookmark N
|
|
459
514
|
/mark bookmark current activity position
|
|
@@ -843,6 +898,103 @@ ${BOLD}other:${RESET}
|
|
|
843
898
|
}
|
|
844
899
|
break;
|
|
845
900
|
}
|
|
901
|
+
case "/mute-errors":
|
|
902
|
+
if (this.muteErrorsHandler) {
|
|
903
|
+
this.muteErrorsHandler();
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
console.error(`${DIM}mute-errors not available (no TUI)${RESET}`);
|
|
907
|
+
}
|
|
908
|
+
break;
|
|
909
|
+
case "/prev-goal": {
|
|
910
|
+
const pgArgs = line.slice("/prev-goal".length).trim().split(/\s+/);
|
|
911
|
+
const pgTarget = pgArgs[0] ?? "";
|
|
912
|
+
const pgN = pgArgs[1] ? parseInt(pgArgs[1], 10) : 1;
|
|
913
|
+
if (!pgTarget) {
|
|
914
|
+
console.error(`${DIM}usage: /prev-goal <N|name> [n] — restore nth most-recent goal (default 1)${RESET}`);
|
|
915
|
+
break;
|
|
916
|
+
}
|
|
917
|
+
if (this.prevGoalHandler) {
|
|
918
|
+
this.prevGoalHandler(pgTarget, isNaN(pgN) || pgN < 1 ? 1 : pgN);
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
console.error(`${DIM}prev-goal not available (no TUI)${RESET}`);
|
|
922
|
+
}
|
|
923
|
+
break;
|
|
924
|
+
}
|
|
925
|
+
case "/tag": {
|
|
926
|
+
const tagArgs = line.slice("/tag".length).trim();
|
|
927
|
+
if (!tagArgs) {
|
|
928
|
+
console.error(`${DIM}usage: /tag <N|name> [tag1,tag2,...] — set tags (no tags = clear)${RESET}`);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
931
|
+
if (this.tagHandler) {
|
|
932
|
+
const spaceIdx = tagArgs.indexOf(" ");
|
|
933
|
+
if (spaceIdx > 0) {
|
|
934
|
+
const target = tagArgs.slice(0, spaceIdx);
|
|
935
|
+
const rawTags = tagArgs.slice(spaceIdx + 1).trim();
|
|
936
|
+
const tags = rawTags ? rawTags.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
937
|
+
this.tagHandler(target, tags);
|
|
938
|
+
}
|
|
939
|
+
else {
|
|
940
|
+
// target only — clear tags
|
|
941
|
+
this.tagHandler(tagArgs, []);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
else {
|
|
945
|
+
console.error(`${DIM}tag not available (no TUI)${RESET}`);
|
|
946
|
+
}
|
|
947
|
+
break;
|
|
948
|
+
}
|
|
949
|
+
case "/tags":
|
|
950
|
+
if (this.tagsListHandler) {
|
|
951
|
+
this.tagsListHandler();
|
|
952
|
+
}
|
|
953
|
+
else {
|
|
954
|
+
console.error(`${DIM}tags not available (no TUI)${RESET}`);
|
|
955
|
+
}
|
|
956
|
+
break;
|
|
957
|
+
case "/pin-all-errors":
|
|
958
|
+
if (this.pinAllErrorsHandler) {
|
|
959
|
+
this.pinAllErrorsHandler();
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
console.error(`${DIM}pin-all-errors not available (no TUI)${RESET}`);
|
|
963
|
+
}
|
|
964
|
+
break;
|
|
965
|
+
case "/export-stats":
|
|
966
|
+
if (this.exportStatsHandler) {
|
|
967
|
+
this.exportStatsHandler();
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
console.error(`${DIM}export-stats not available (no TUI)${RESET}`);
|
|
971
|
+
}
|
|
972
|
+
break;
|
|
973
|
+
case "/recall": {
|
|
974
|
+
const recallArgs = line.slice("/recall".length).trim().split(/\s+/);
|
|
975
|
+
const keyword = recallArgs[0] ?? "";
|
|
976
|
+
if (!keyword) {
|
|
977
|
+
console.error(`${DIM}usage: /recall <keyword> [N] — search activity history (default: last 50 matches)${RESET}`);
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
const maxN = recallArgs[1] ? parseInt(recallArgs[1], 10) : 50;
|
|
981
|
+
const limit = isNaN(maxN) || maxN < 1 ? 50 : Math.min(maxN, 500);
|
|
982
|
+
if (this.recallHandler) {
|
|
983
|
+
this.recallHandler(keyword, limit);
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
console.error(`${DIM}recall not available (no TUI)${RESET}`);
|
|
987
|
+
}
|
|
988
|
+
break;
|
|
989
|
+
}
|
|
990
|
+
case "/stats":
|
|
991
|
+
if (this.statsHandler) {
|
|
992
|
+
this.statsHandler();
|
|
993
|
+
}
|
|
994
|
+
else {
|
|
995
|
+
console.error(`${DIM}stats not available (no TUI)${RESET}`);
|
|
996
|
+
}
|
|
997
|
+
break;
|
|
846
998
|
case "/copy": {
|
|
847
999
|
const copyArg = line.slice("/copy".length).trim() || null;
|
|
848
1000
|
if (this.copySessionHandler) {
|
package/dist/tui-history.d.ts
CHANGED
|
@@ -25,4 +25,10 @@ export declare function loadTuiHistory(maxEntries?: number, filePath?: string, m
|
|
|
25
25
|
export declare function rotateTuiHistory(filePath?: string, maxSize?: number): boolean;
|
|
26
26
|
/** Default history file path (for wiring in index.ts) */
|
|
27
27
|
export declare const TUI_HISTORY_FILE: string;
|
|
28
|
+
/**
|
|
29
|
+
* Search history entries by keyword (case-insensitive substring match on text and tag).
|
|
30
|
+
* Returns up to `maxResults` most recent matching entries (newest last).
|
|
31
|
+
* Searches both the current and .old history file.
|
|
32
|
+
*/
|
|
33
|
+
export declare function searchHistory(keyword: string, maxResults?: number, filePath?: string, maxAgeMs?: number): HistoryEntry[];
|
|
28
34
|
//# sourceMappingURL=tui-history.d.ts.map
|
package/dist/tui-history.js
CHANGED
|
@@ -88,4 +88,51 @@ function isValidEntry(val) {
|
|
|
88
88
|
}
|
|
89
89
|
/** Default history file path (for wiring in index.ts) */
|
|
90
90
|
export const TUI_HISTORY_FILE = HISTORY_FILE;
|
|
91
|
+
/**
|
|
92
|
+
* Search history entries by keyword (case-insensitive substring match on text and tag).
|
|
93
|
+
* Returns up to `maxResults` most recent matching entries (newest last).
|
|
94
|
+
* Searches both the current and .old history file.
|
|
95
|
+
*/
|
|
96
|
+
export function searchHistory(keyword, maxResults = 50, filePath = HISTORY_FILE, maxAgeMs = 7 * 24 * 60 * 60 * 1000) {
|
|
97
|
+
const lower = keyword.toLowerCase();
|
|
98
|
+
const results = [];
|
|
99
|
+
// helper: collect matches from one file
|
|
100
|
+
const collectFrom = (fp) => {
|
|
101
|
+
try {
|
|
102
|
+
if (!existsSync(fp))
|
|
103
|
+
return;
|
|
104
|
+
const content = readFileSync(fp, "utf-8");
|
|
105
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
106
|
+
for (const line of content.split("\n")) {
|
|
107
|
+
if (!line.trim())
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const e = JSON.parse(line);
|
|
111
|
+
if (!isValidEntry(e) || e.ts < cutoff)
|
|
112
|
+
continue;
|
|
113
|
+
if (e.text.toLowerCase().includes(lower) || e.tag.toLowerCase().includes(lower)) {
|
|
114
|
+
results.push(e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch { /* skip malformed */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch { /* ignore unreadable file */ }
|
|
121
|
+
};
|
|
122
|
+
// search .old first (older), then current (newer)
|
|
123
|
+
collectFrom(filePath + ".old");
|
|
124
|
+
collectFrom(filePath);
|
|
125
|
+
// deduplicate by ts+text, sort newest last, cap to maxResults
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const deduped = [];
|
|
128
|
+
for (const e of results) {
|
|
129
|
+
const key = `${e.ts}:${e.text}`;
|
|
130
|
+
if (!seen.has(key)) {
|
|
131
|
+
seen.add(key);
|
|
132
|
+
deduped.push(e);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
deduped.sort((a, b) => a.ts - b.ts);
|
|
136
|
+
return deduped.slice(-maxResults);
|
|
137
|
+
}
|
|
91
138
|
//# sourceMappingURL=tui-history.js.map
|
package/dist/tui.d.ts
CHANGED
|
@@ -31,9 +31,29 @@ declare const PIN_ICON = "\u25B2";
|
|
|
31
31
|
* Each token: "{idx}{pin?}{mute?}{dot}{name}{health?}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
|
|
32
32
|
* Returns array of formatted row strings (one per display row).
|
|
33
33
|
*/
|
|
34
|
-
declare function formatCompactRows(sessions: DaemonSessionState[], maxWidth: number, pinnedIds?: Set<string>, mutedIds?: Set<string>, noteIds?: Set<string>, healthScores?: Map<string, number>): string[];
|
|
34
|
+
declare function formatCompactRows(sessions: DaemonSessionState[], maxWidth: number, pinnedIds?: Set<string>, mutedIds?: Set<string>, noteIds?: Set<string>, healthScores?: Map<string, number>, activityRates?: Map<string, number>): string[];
|
|
35
35
|
/** Compute how many display rows compact mode needs (minimum 1). */
|
|
36
36
|
declare function computeCompactRowCount(sessions: DaemonSessionState[], maxWidth: number): number;
|
|
37
|
+
/** Window for computing per-session activity rate (5 minutes). */
|
|
38
|
+
export declare const ACTIVITY_RATE_WINDOW_MS: number;
|
|
39
|
+
/**
|
|
40
|
+
* Compute messages-per-minute for a session from the activity buffer.
|
|
41
|
+
* Only counts entries within the last ACTIVITY_RATE_WINDOW_MS.
|
|
42
|
+
* Returns 0 when no activity in window.
|
|
43
|
+
*/
|
|
44
|
+
export declare function computeSessionActivityRate(buffer: readonly {
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
}[], timestamps: readonly number[], sessionId: string, now?: number, windowMs?: number): number;
|
|
47
|
+
/**
|
|
48
|
+
* Format activity rate as a compact badge for compact mode tokens.
|
|
49
|
+
* Returns empty string when rate is 0 (no clutter for quiet sessions).
|
|
50
|
+
* Format: "3/m" (rounded to nearest integer messages/min).
|
|
51
|
+
*/
|
|
52
|
+
export declare function formatActivityRateBadge(rate: number): string;
|
|
53
|
+
/** Format watchdog status tag for the header (empty string when disabled). */
|
|
54
|
+
export declare function formatWatchdogTag(thresholdMs: number | null): string;
|
|
55
|
+
/** Format group filter tag for the header (empty string when no filter). */
|
|
56
|
+
export declare function formatGroupFilterTag(groupFilter: string | null): string;
|
|
37
57
|
declare function phaseDisplay(phase: DaemonPhase, paused: boolean, spinnerFrame: number): string;
|
|
38
58
|
export interface ActivityEntry {
|
|
39
59
|
time: string;
|
|
@@ -74,6 +94,45 @@ export declare function formatUptime(ms: number): string;
|
|
|
74
94
|
* Returns empty string if under threshold (< thresholdMs, default 2 min — not worth showing).
|
|
75
95
|
*/
|
|
76
96
|
export declare function formatIdleSince(ms: number, thresholdMs?: number): string;
|
|
97
|
+
/**
|
|
98
|
+
* Check if an activity entry's tag matches any tag in the suppressed set.
|
|
99
|
+
* Handles pipe-separated patterns (same logic as matchesTagFilter).
|
|
100
|
+
*/
|
|
101
|
+
export declare function isSuppressedEntry(entry: ActivityEntry, suppressedTags: ReadonlySet<string>): boolean;
|
|
102
|
+
/** Default error-suppression pattern matching "! action" and "error" tags. */
|
|
103
|
+
export declare const MUTE_ERRORS_PATTERN = "error|! action";
|
|
104
|
+
/** Max previous goals stored per session. */
|
|
105
|
+
export declare const MAX_GOAL_HISTORY = 5;
|
|
106
|
+
/** Max tags per session. */
|
|
107
|
+
export declare const MAX_SESSION_TAGS = 10;
|
|
108
|
+
/** Max length of a single session tag. */
|
|
109
|
+
export declare const MAX_SESSION_TAG_LEN = 20;
|
|
110
|
+
/** Validate a session tag name. Returns null if valid, error string if not. */
|
|
111
|
+
export declare function validateSessionTag(tag: string): string | null;
|
|
112
|
+
/** Format a session tags badge for display in cards. Returns empty string if no tags. */
|
|
113
|
+
export declare function formatSessionTagsBadge(tags: ReadonlySet<string>): string;
|
|
114
|
+
export interface SessionStatEntry {
|
|
115
|
+
title: string;
|
|
116
|
+
displayName?: string;
|
|
117
|
+
status: string;
|
|
118
|
+
health: number;
|
|
119
|
+
errors: number;
|
|
120
|
+
burnRatePerMin: number | null;
|
|
121
|
+
contextPct: number | null;
|
|
122
|
+
uptimeMs: number | null;
|
|
123
|
+
idleSinceMs: number | null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build stats entries for all sessions — pure, testable, no side effects.
|
|
127
|
+
*/
|
|
128
|
+
export declare function buildSessionStats(sessions: readonly DaemonSessionState[], errorCounts: ReadonlyMap<string, number>, burnRates: ReadonlyMap<string, number | null>, firstSeen: ReadonlyMap<string, number>, lastChangeAt: ReadonlyMap<string, number>, healthScores: ReadonlyMap<string, number>, sessionAliases: ReadonlyMap<string, string>, now?: number): SessionStatEntry[];
|
|
129
|
+
/**
|
|
130
|
+
* Format session stats entries as a multi-line activity-log-friendly string.
|
|
131
|
+
* Each line is one session summary.
|
|
132
|
+
*/
|
|
133
|
+
export declare function formatSessionStatsLines(entries: SessionStatEntry[]): string[];
|
|
134
|
+
/** Format session stats entries as a JSON object for export. */
|
|
135
|
+
export declare function formatStatsJson(entries: SessionStatEntry[], version: string, now?: number): string;
|
|
77
136
|
/** Max visible length for a custom session display name. */
|
|
78
137
|
export declare const MAX_RENAME_LEN = 32;
|
|
79
138
|
/** Truncate a custom display name to the max length. */
|
|
@@ -171,6 +230,7 @@ export interface TuiPrefs {
|
|
|
171
230
|
aliases?: Record<string, string>;
|
|
172
231
|
sessionGroups?: Record<string, string>;
|
|
173
232
|
sessionAliases?: Record<string, string>;
|
|
233
|
+
sessionTags?: Record<string, string[]>;
|
|
174
234
|
}
|
|
175
235
|
/** Load persisted TUI preferences. Returns empty object on any error. */
|
|
176
236
|
export declare function loadTuiPrefs(): TuiPrefs;
|
|
@@ -221,6 +281,9 @@ export declare class TUI {
|
|
|
221
281
|
private sessionAliases;
|
|
222
282
|
private watchdogThresholdMs;
|
|
223
283
|
private watchdogAlerted;
|
|
284
|
+
private suppressedTags;
|
|
285
|
+
private sessionGoalHistory;
|
|
286
|
+
private sessionTags;
|
|
224
287
|
private viewMode;
|
|
225
288
|
private drilldownSessionId;
|
|
226
289
|
private sessionOutputs;
|
|
@@ -251,6 +314,11 @@ export declare class TUI {
|
|
|
251
314
|
* Pinned sessions always sort to the top. Returns true if session found.
|
|
252
315
|
*/
|
|
253
316
|
togglePin(sessionIdOrIndex: string | number): boolean;
|
|
317
|
+
/**
|
|
318
|
+
* Pin all sessions currently in "error" status (or with any cumulative errors).
|
|
319
|
+
* Returns the count of newly pinned sessions.
|
|
320
|
+
*/
|
|
321
|
+
pinAllErrors(): number;
|
|
254
322
|
/** Check if a session ID is pinned. */
|
|
255
323
|
isPinned(id: string): boolean;
|
|
256
324
|
/** Return count of pinned sessions. */
|
|
@@ -304,6 +372,33 @@ export declare class TUI {
|
|
|
304
372
|
getAllFirstSeen(): ReadonlyMap<string, number>;
|
|
305
373
|
/** Return the activity buffer (for /clip export). */
|
|
306
374
|
getActivityBuffer(): readonly ActivityEntry[];
|
|
375
|
+
/** Toggle suppression of error-tagged entries ("! action" and "error"). */
|
|
376
|
+
toggleMuteErrors(): boolean;
|
|
377
|
+
/** Return whether error tags are currently suppressed. */
|
|
378
|
+
isErrorsMuted(): boolean;
|
|
379
|
+
/** Return the full suppressed-tags set (readonly). */
|
|
380
|
+
getSuppressedTags(): ReadonlySet<string>;
|
|
381
|
+
/** Record a goal for a session (push to front of history, cap at MAX_GOAL_HISTORY). */
|
|
382
|
+
pushGoalHistory(sessionId: string, goal: string): void;
|
|
383
|
+
/** Get goal history for a session (oldest first, most recent last). */
|
|
384
|
+
getGoalHistory(sessionId: string): readonly string[];
|
|
385
|
+
/** Restore a previous goal (1 = most recent, 2 = two back, etc.). Returns the goal string or null. */
|
|
386
|
+
getPreviousGoal(sessionId: string, nBack?: number): string | null;
|
|
387
|
+
/**
|
|
388
|
+
* Set tags for a session (replaces existing). Pass empty array to clear.
|
|
389
|
+
* Returns true if session found.
|
|
390
|
+
*/
|
|
391
|
+
setSessionTags(sessionIdOrIndex: string | number, tags: string[]): boolean;
|
|
392
|
+
/** Get tags for a session ID (empty set if none). */
|
|
393
|
+
getSessionTags(id: string): ReadonlySet<string>;
|
|
394
|
+
/** Return all session tags (id → tag set). */
|
|
395
|
+
getAllSessionTags(): ReadonlyMap<string, ReadonlySet<string>>;
|
|
396
|
+
/** Return sessions that have a given tag. */
|
|
397
|
+
getSessionsWithTag(tag: string): DaemonSessionState[];
|
|
398
|
+
/** Restore session tags from persisted prefs. */
|
|
399
|
+
restoreSessionTags(tags: Record<string, string[]>): void;
|
|
400
|
+
/** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
|
|
401
|
+
getActivityTimestamps(): readonly number[];
|
|
307
402
|
/**
|
|
308
403
|
* Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
|
|
309
404
|
* Returns null if session not found or no output stored.
|
|
@@ -322,6 +417,8 @@ export declare class TUI {
|
|
|
322
417
|
current: number;
|
|
323
418
|
max: number;
|
|
324
419
|
} | null>;
|
|
420
|
+
/** Compute health scores for all sessions and return as a map (id → score). */
|
|
421
|
+
getAllHealthScores(now?: number): Map<string, number>;
|
|
325
422
|
/** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
|
|
326
423
|
getAllBurnRates(now?: number): Map<string, number | null>;
|
|
327
424
|
/**
|
|
@@ -385,6 +482,8 @@ export declare class TUI {
|
|
|
385
482
|
}): void;
|
|
386
483
|
log(tag: string, text: string, sessionId?: string): void;
|
|
387
484
|
replayHistory(entries: HistoryEntry[]): void;
|
|
485
|
+
/** Apply all active display filters to an entry array: mute, suppress, tag, search. */
|
|
486
|
+
private applyDisplayFilters;
|
|
388
487
|
scrollUp(lines?: number): void;
|
|
389
488
|
scrollDown(lines?: number): void;
|
|
390
489
|
scrollToTop(): void;
|
package/dist/tui.js
CHANGED
|
@@ -103,7 +103,7 @@ const PIN_ICON = "▲";
|
|
|
103
103
|
* Each token: "{idx}{pin?}{mute?}{dot}{name}{health?}" — e.g. "1▲●Alpha" for pinned, "2◌●Bravo" for muted.
|
|
104
104
|
* Returns array of formatted row strings (one per display row).
|
|
105
105
|
*/
|
|
106
|
-
function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, healthScores) {
|
|
106
|
+
function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, healthScores, activityRates) {
|
|
107
107
|
if (sessions.length === 0)
|
|
108
108
|
return [`${DIM}no agents connected${RESET}`];
|
|
109
109
|
const tokens = [];
|
|
@@ -124,8 +124,12 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, hea
|
|
|
124
124
|
const healthGlyph = (score !== undefined && score < HEALTH_GOOD)
|
|
125
125
|
? `${score < HEALTH_WARN ? ROSE : AMBER}${HEALTH_ICON}${RESET}` : "";
|
|
126
126
|
const healthWidth = (score !== undefined && score < HEALTH_GOOD) ? 1 : 0;
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
// activity rate badge: "3/m" when rate > 0
|
|
128
|
+
const rate = activityRates?.get(s.id) ?? 0;
|
|
129
|
+
const rateBadge = formatActivityRateBadge(rate);
|
|
130
|
+
const rateVisible = rateBadge ? stripAnsiForLen(rateBadge) : 0;
|
|
131
|
+
tokens.push(`${SLATE}${idx}${RESET}${pin}${muteIcon}${noteIcon}${dot}${BOLD}${name}${RESET}${healthGlyph}${rateBadge}`);
|
|
132
|
+
widths.push(idx.length + (pinned ? 1 : 0) + (muted ? 1 : 0) + (noted ? 1 : 0) + 1 + name.length + healthWidth + rateVisible);
|
|
129
133
|
}
|
|
130
134
|
const rows = [];
|
|
131
135
|
let currentRow = "";
|
|
@@ -150,6 +154,49 @@ function formatCompactRows(sessions, maxWidth, pinnedIds, mutedIds, noteIds, hea
|
|
|
150
154
|
function computeCompactRowCount(sessions, maxWidth) {
|
|
151
155
|
return Math.max(1, formatCompactRows(sessions, maxWidth).length);
|
|
152
156
|
}
|
|
157
|
+
// ── Activity rate helpers (pure, exported for testing) ───────────────────────
|
|
158
|
+
/** Window for computing per-session activity rate (5 minutes). */
|
|
159
|
+
export const ACTIVITY_RATE_WINDOW_MS = 5 * 60_000;
|
|
160
|
+
/**
|
|
161
|
+
* Compute messages-per-minute for a session from the activity buffer.
|
|
162
|
+
* Only counts entries within the last ACTIVITY_RATE_WINDOW_MS.
|
|
163
|
+
* Returns 0 when no activity in window.
|
|
164
|
+
*/
|
|
165
|
+
export function computeSessionActivityRate(buffer, timestamps, sessionId, now, windowMs = ACTIVITY_RATE_WINDOW_MS) {
|
|
166
|
+
const nowMs = now ?? Date.now();
|
|
167
|
+
const cutoff = nowMs - windowMs;
|
|
168
|
+
let count = 0;
|
|
169
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
170
|
+
if (buffer[i].sessionId === sessionId && (timestamps[i] ?? 0) >= cutoff)
|
|
171
|
+
count++;
|
|
172
|
+
}
|
|
173
|
+
return count === 0 ? 0 : (count / windowMs) * 60_000;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Format activity rate as a compact badge for compact mode tokens.
|
|
177
|
+
* Returns empty string when rate is 0 (no clutter for quiet sessions).
|
|
178
|
+
* Format: "3/m" (rounded to nearest integer messages/min).
|
|
179
|
+
*/
|
|
180
|
+
export function formatActivityRateBadge(rate) {
|
|
181
|
+
if (rate <= 0)
|
|
182
|
+
return "";
|
|
183
|
+
const rounded = Math.max(1, Math.round(rate));
|
|
184
|
+
return `${DIM}${rounded}/m${RESET}`;
|
|
185
|
+
}
|
|
186
|
+
// ── Header status tag helpers (pure, exported for testing) ──────────────────
|
|
187
|
+
/** Format watchdog status tag for the header (empty string when disabled). */
|
|
188
|
+
export function formatWatchdogTag(thresholdMs) {
|
|
189
|
+
if (thresholdMs === null)
|
|
190
|
+
return "";
|
|
191
|
+
const mins = Math.round(thresholdMs / 60_000);
|
|
192
|
+
return `⊛${mins}m`;
|
|
193
|
+
}
|
|
194
|
+
/** Format group filter tag for the header (empty string when no filter). */
|
|
195
|
+
export function formatGroupFilterTag(groupFilter) {
|
|
196
|
+
if (!groupFilter)
|
|
197
|
+
return "";
|
|
198
|
+
return `${GROUP_ICON}${groupFilter}`;
|
|
199
|
+
}
|
|
153
200
|
// ── Status rendering ────────────────────────────────────────────────────────
|
|
154
201
|
const STATUS_DOT = {
|
|
155
202
|
working: `${LIME}${DOT.filled}${RESET}`,
|
|
@@ -264,6 +311,97 @@ export function formatIdleSince(ms, thresholdMs = 2 * 60_000) {
|
|
|
264
311
|
return "";
|
|
265
312
|
return `idle ${formatUptime(ms)}`;
|
|
266
313
|
}
|
|
314
|
+
// ── Suppressed tags (mute-errors style) ──────────────────────────────────────
|
|
315
|
+
/**
|
|
316
|
+
* Check if an activity entry's tag matches any tag in the suppressed set.
|
|
317
|
+
* Handles pipe-separated patterns (same logic as matchesTagFilter).
|
|
318
|
+
*/
|
|
319
|
+
export function isSuppressedEntry(entry, suppressedTags) {
|
|
320
|
+
if (suppressedTags.size === 0)
|
|
321
|
+
return false;
|
|
322
|
+
for (const pattern of suppressedTags) {
|
|
323
|
+
if (matchesTagFilter(entry, pattern))
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
/** Default error-suppression pattern matching "! action" and "error" tags. */
|
|
329
|
+
export const MUTE_ERRORS_PATTERN = "error|! action";
|
|
330
|
+
// ── Per-session goal history ──────────────────────────────────────────────────
|
|
331
|
+
/** Max previous goals stored per session. */
|
|
332
|
+
export const MAX_GOAL_HISTORY = 5;
|
|
333
|
+
// ── Session multi-tags ────────────────────────────────────────────────────────
|
|
334
|
+
/** Max tags per session. */
|
|
335
|
+
export const MAX_SESSION_TAGS = 10;
|
|
336
|
+
/** Max length of a single session tag. */
|
|
337
|
+
export const MAX_SESSION_TAG_LEN = 20;
|
|
338
|
+
/** Validate a session tag name. Returns null if valid, error string if not. */
|
|
339
|
+
export function validateSessionTag(tag) {
|
|
340
|
+
if (!tag || tag.trim().length === 0)
|
|
341
|
+
return "tag cannot be empty";
|
|
342
|
+
const t = tag.trim();
|
|
343
|
+
if (t.length > MAX_SESSION_TAG_LEN)
|
|
344
|
+
return `tag too long (max ${MAX_SESSION_TAG_LEN})`;
|
|
345
|
+
if (!/^[a-z0-9_-]+$/i.test(t))
|
|
346
|
+
return "tag must be alphanumeric (a-z, 0-9, - _)";
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
/** Format a session tags badge for display in cards. Returns empty string if no tags. */
|
|
350
|
+
export function formatSessionTagsBadge(tags) {
|
|
351
|
+
if (tags.size === 0)
|
|
352
|
+
return "";
|
|
353
|
+
return `${DIM}[${[...tags].sort().join(",")}]${RESET}`;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Build stats entries for all sessions — pure, testable, no side effects.
|
|
357
|
+
*/
|
|
358
|
+
export function buildSessionStats(sessions, errorCounts, burnRates, firstSeen, lastChangeAt, healthScores, sessionAliases, now) {
|
|
359
|
+
const nowMs = now ?? Date.now();
|
|
360
|
+
return sessions.map((s) => {
|
|
361
|
+
const ceiling = parseContextCeiling(s.contextTokens);
|
|
362
|
+
const ctxPct = ceiling ? Math.round((ceiling.current / ceiling.max) * 100) : null;
|
|
363
|
+
const fs = firstSeen.get(s.id);
|
|
364
|
+
const lc = lastChangeAt.get(s.id);
|
|
365
|
+
return {
|
|
366
|
+
title: s.title,
|
|
367
|
+
displayName: sessionAliases.get(s.id),
|
|
368
|
+
status: s.status,
|
|
369
|
+
health: healthScores.get(s.id) ?? 100,
|
|
370
|
+
errors: errorCounts.get(s.id) ?? 0,
|
|
371
|
+
burnRatePerMin: burnRates.get(s.id) ?? null,
|
|
372
|
+
contextPct: ctxPct,
|
|
373
|
+
uptimeMs: fs !== undefined ? nowMs - fs : null,
|
|
374
|
+
idleSinceMs: lc !== undefined ? nowMs - lc : null,
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Format session stats entries as a multi-line activity-log-friendly string.
|
|
380
|
+
* Each line is one session summary.
|
|
381
|
+
*/
|
|
382
|
+
export function formatSessionStatsLines(entries) {
|
|
383
|
+
if (entries.length === 0)
|
|
384
|
+
return [" no sessions"];
|
|
385
|
+
return entries.map((e) => {
|
|
386
|
+
const label = e.displayName ? `${e.displayName} (${e.title})` : e.title;
|
|
387
|
+
const healthStr = `⬡${e.health}`;
|
|
388
|
+
const errStr = e.errors > 0 ? ` ${e.errors}err` : "";
|
|
389
|
+
const burnStr = e.burnRatePerMin !== null && e.burnRatePerMin > 0
|
|
390
|
+
? ` ${Math.round(e.burnRatePerMin / 100) * 100}tok/min` : "";
|
|
391
|
+
const ctxStr = e.contextPct !== null ? ` ctx:${e.contextPct}%` : "";
|
|
392
|
+
const upStr = e.uptimeMs !== null ? ` up:${formatUptime(e.uptimeMs)}` : "";
|
|
393
|
+
const idleStr = e.idleSinceMs !== null ? ` ${formatIdleSince(e.idleSinceMs)}` : "";
|
|
394
|
+
return ` ${label} [${e.status}] ${healthStr}${errStr}${burnStr}${ctxStr}${upStr}${idleStr}`;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/** Format session stats entries as a JSON object for export. */
|
|
398
|
+
export function formatStatsJson(entries, version, now) {
|
|
399
|
+
return JSON.stringify({
|
|
400
|
+
version,
|
|
401
|
+
exportedAt: new Date(now ?? Date.now()).toISOString(),
|
|
402
|
+
sessions: entries,
|
|
403
|
+
}, null, 2) + "\n";
|
|
404
|
+
}
|
|
267
405
|
// ── Session rename ────────────────────────────────────────────────────────────
|
|
268
406
|
/** Max visible length for a custom session display name. */
|
|
269
407
|
export const MAX_RENAME_LEN = 32;
|
|
@@ -286,7 +424,8 @@ export const BUILTIN_COMMANDS = new Set([
|
|
|
286
424
|
"/pin", "/bell", "/focus", "/mute", "/unmute-all", "/filter", "/who",
|
|
287
425
|
"/uptime", "/auto-pin", "/note", "/notes", "/clip", "/diff", "/mark",
|
|
288
426
|
"/jump", "/marks", "/search", "/alias", "/insist", "/task", "/tasks",
|
|
289
|
-
"/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy",
|
|
427
|
+
"/group", "/groups", "/group-filter", "/burn-rate", "/snapshot", "/broadcast", "/watchdog", "/top", "/ceiling", "/rename", "/copy", "/stats", "/recall", "/pin-all-errors", "/export-stats",
|
|
428
|
+
"/mute-errors", "/prev-goal", "/tag", "/tags",
|
|
290
429
|
]);
|
|
291
430
|
/** Resolve a slash command through the alias map. Returns the expanded command or the original. */
|
|
292
431
|
export function resolveAlias(line, aliases) {
|
|
@@ -551,6 +690,9 @@ export class TUI {
|
|
|
551
690
|
sessionAliases = new Map(); // session ID → custom display name
|
|
552
691
|
watchdogThresholdMs = null; // null = disabled; ms of inactivity before alert
|
|
553
692
|
watchdogAlerted = new Map(); // session ID → epoch ms of last watchdog alert
|
|
693
|
+
suppressedTags = new Set(); // activity tags excluded from display (/mute-errors)
|
|
694
|
+
sessionGoalHistory = new Map(); // session ID → last N goals (newest last)
|
|
695
|
+
sessionTags = new Map(); // session ID → freeform tag set
|
|
554
696
|
// drill-down mode: show a single session's full output
|
|
555
697
|
viewMode = "overview";
|
|
556
698
|
drilldownSessionId = null;
|
|
@@ -665,6 +807,25 @@ export class TUI {
|
|
|
665
807
|
}
|
|
666
808
|
return true;
|
|
667
809
|
}
|
|
810
|
+
/**
|
|
811
|
+
* Pin all sessions currently in "error" status (or with any cumulative errors).
|
|
812
|
+
* Returns the count of newly pinned sessions.
|
|
813
|
+
*/
|
|
814
|
+
pinAllErrors() {
|
|
815
|
+
let pinned = 0;
|
|
816
|
+
for (const s of this.sessions) {
|
|
817
|
+
if ((s.status === "error" || (this.sessionErrorCounts.get(s.id) ?? 0) > 0) && !this.pinnedIds.has(s.id)) {
|
|
818
|
+
this.pinnedIds.add(s.id);
|
|
819
|
+
pinned++;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
if (pinned > 0) {
|
|
823
|
+
this.sessions = sortSessions(this.sessions, this.sortMode, this.lastChangeAt, this.pinnedIds);
|
|
824
|
+
if (this.active)
|
|
825
|
+
this.paintSessions();
|
|
826
|
+
}
|
|
827
|
+
return pinned;
|
|
828
|
+
}
|
|
668
829
|
/** Check if a session ID is pinned. */
|
|
669
830
|
isPinned(id) {
|
|
670
831
|
return this.pinnedIds.has(id);
|
|
@@ -834,6 +995,105 @@ export class TUI {
|
|
|
834
995
|
getActivityBuffer() {
|
|
835
996
|
return this.activityBuffer;
|
|
836
997
|
}
|
|
998
|
+
// ── Suppressed tags ─────────────────────────────────────────────────────
|
|
999
|
+
/** Toggle suppression of error-tagged entries ("! action" and "error"). */
|
|
1000
|
+
toggleMuteErrors() {
|
|
1001
|
+
if (this.suppressedTags.has(MUTE_ERRORS_PATTERN)) {
|
|
1002
|
+
this.suppressedTags.delete(MUTE_ERRORS_PATTERN);
|
|
1003
|
+
if (this.active)
|
|
1004
|
+
this.repaintActivityRegion();
|
|
1005
|
+
return false; // now unmuted
|
|
1006
|
+
}
|
|
1007
|
+
this.suppressedTags.add(MUTE_ERRORS_PATTERN);
|
|
1008
|
+
if (this.active)
|
|
1009
|
+
this.repaintActivityRegion();
|
|
1010
|
+
return true; // now muted
|
|
1011
|
+
}
|
|
1012
|
+
/** Return whether error tags are currently suppressed. */
|
|
1013
|
+
isErrorsMuted() {
|
|
1014
|
+
return this.suppressedTags.has(MUTE_ERRORS_PATTERN);
|
|
1015
|
+
}
|
|
1016
|
+
/** Return the full suppressed-tags set (readonly). */
|
|
1017
|
+
getSuppressedTags() {
|
|
1018
|
+
return this.suppressedTags;
|
|
1019
|
+
}
|
|
1020
|
+
// ── Per-session goal history ─────────────────────────────────────────────
|
|
1021
|
+
/** Record a goal for a session (push to front of history, cap at MAX_GOAL_HISTORY). */
|
|
1022
|
+
pushGoalHistory(sessionId, goal) {
|
|
1023
|
+
if (!goal || !goal.trim())
|
|
1024
|
+
return;
|
|
1025
|
+
const hist = this.sessionGoalHistory.get(sessionId) ?? [];
|
|
1026
|
+
// avoid duplicating the same consecutive goal
|
|
1027
|
+
if (hist.length > 0 && hist[hist.length - 1] === goal.trim())
|
|
1028
|
+
return;
|
|
1029
|
+
hist.push(goal.trim());
|
|
1030
|
+
if (hist.length > MAX_GOAL_HISTORY)
|
|
1031
|
+
hist.shift();
|
|
1032
|
+
this.sessionGoalHistory.set(sessionId, hist);
|
|
1033
|
+
}
|
|
1034
|
+
/** Get goal history for a session (oldest first, most recent last). */
|
|
1035
|
+
getGoalHistory(sessionId) {
|
|
1036
|
+
return this.sessionGoalHistory.get(sessionId) ?? [];
|
|
1037
|
+
}
|
|
1038
|
+
/** Restore a previous goal (1 = most recent, 2 = two back, etc.). Returns the goal string or null. */
|
|
1039
|
+
getPreviousGoal(sessionId, nBack = 1) {
|
|
1040
|
+
const hist = this.sessionGoalHistory.get(sessionId) ?? [];
|
|
1041
|
+
const idx = hist.length - nBack;
|
|
1042
|
+
return idx >= 0 ? hist[idx] : null;
|
|
1043
|
+
}
|
|
1044
|
+
// ── Session multi-tags ───────────────────────────────────────────────────
|
|
1045
|
+
/**
|
|
1046
|
+
* Set tags for a session (replaces existing). Pass empty array to clear.
|
|
1047
|
+
* Returns true if session found.
|
|
1048
|
+
*/
|
|
1049
|
+
setSessionTags(sessionIdOrIndex, tags) {
|
|
1050
|
+
let sessionId;
|
|
1051
|
+
if (typeof sessionIdOrIndex === "number") {
|
|
1052
|
+
sessionId = this.sessions[sessionIdOrIndex - 1]?.id;
|
|
1053
|
+
}
|
|
1054
|
+
else {
|
|
1055
|
+
const needle = sessionIdOrIndex.toLowerCase();
|
|
1056
|
+
const match = this.sessions.find((s) => s.id === sessionIdOrIndex || s.id.startsWith(needle) || s.title.toLowerCase() === needle);
|
|
1057
|
+
sessionId = match?.id;
|
|
1058
|
+
}
|
|
1059
|
+
if (!sessionId)
|
|
1060
|
+
return false;
|
|
1061
|
+
if (tags.length === 0) {
|
|
1062
|
+
this.sessionTags.delete(sessionId);
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
const tagSet = new Set(tags.map((t) => t.trim().toLowerCase()).filter(Boolean));
|
|
1066
|
+
this.sessionTags.set(sessionId, tagSet);
|
|
1067
|
+
}
|
|
1068
|
+
if (this.active)
|
|
1069
|
+
this.paintSessions();
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
1072
|
+
/** Get tags for a session ID (empty set if none). */
|
|
1073
|
+
getSessionTags(id) {
|
|
1074
|
+
return this.sessionTags.get(id) ?? new Set();
|
|
1075
|
+
}
|
|
1076
|
+
/** Return all session tags (id → tag set). */
|
|
1077
|
+
getAllSessionTags() {
|
|
1078
|
+
return this.sessionTags;
|
|
1079
|
+
}
|
|
1080
|
+
/** Return sessions that have a given tag. */
|
|
1081
|
+
getSessionsWithTag(tag) {
|
|
1082
|
+
const lower = tag.toLowerCase();
|
|
1083
|
+
return this.sessions.filter((s) => this.sessionTags.get(s.id)?.has(lower));
|
|
1084
|
+
}
|
|
1085
|
+
/** Restore session tags from persisted prefs. */
|
|
1086
|
+
restoreSessionTags(tags) {
|
|
1087
|
+
this.sessionTags.clear();
|
|
1088
|
+
for (const [id, arr] of Object.entries(tags)) {
|
|
1089
|
+
if (arr.length > 0)
|
|
1090
|
+
this.sessionTags.set(id, new Set(arr));
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
/** Return the activity timestamps (epoch ms per entry, parallel to activityBuffer). */
|
|
1094
|
+
getActivityTimestamps() {
|
|
1095
|
+
return this.activityTimestamps;
|
|
1096
|
+
}
|
|
837
1097
|
/**
|
|
838
1098
|
* Return the stored pane output lines for a session (by 1-indexed number, ID, prefix, or title).
|
|
839
1099
|
* Returns null if session not found or no output stored.
|
|
@@ -876,6 +1136,27 @@ export class TUI {
|
|
|
876
1136
|
}
|
|
877
1137
|
return result;
|
|
878
1138
|
}
|
|
1139
|
+
/** Compute health scores for all sessions and return as a map (id → score). */
|
|
1140
|
+
getAllHealthScores(now) {
|
|
1141
|
+
const nowMs = now ?? Date.now();
|
|
1142
|
+
const result = new Map();
|
|
1143
|
+
for (const s of this.sessions) {
|
|
1144
|
+
const ceiling = parseContextCeiling(s.contextTokens);
|
|
1145
|
+
const cf = ceiling ? ceiling.current / ceiling.max : null;
|
|
1146
|
+
const bh = this.sessionContextHistory.get(s.id);
|
|
1147
|
+
const br = bh ? computeContextBurnRate(bh, nowMs) : null;
|
|
1148
|
+
const lc = this.lastChangeAt.get(s.id);
|
|
1149
|
+
const idle = lc !== undefined ? nowMs - lc : null;
|
|
1150
|
+
result.set(s.id, computeHealthScore({
|
|
1151
|
+
errorCount: this.sessionErrorCounts.get(s.id) ?? 0,
|
|
1152
|
+
burnRatePerMin: br,
|
|
1153
|
+
contextFraction: cf,
|
|
1154
|
+
idleMs: idle,
|
|
1155
|
+
watchdogThresholdMs: this.watchdogThresholdMs,
|
|
1156
|
+
}));
|
|
1157
|
+
}
|
|
1158
|
+
return result;
|
|
1159
|
+
}
|
|
879
1160
|
/** Return all sessions with their current burn rates (tokens/min, null if insufficient data). */
|
|
880
1161
|
getAllBurnRates(now) {
|
|
881
1162
|
const result = new Map();
|
|
@@ -1186,6 +1467,9 @@ export class TUI {
|
|
|
1186
1467
|
if (shouldMuteEntry(entry, this.mutedIds)) {
|
|
1187
1468
|
// silently skip display — entry is in buffer for scroll-back if unmuted later
|
|
1188
1469
|
}
|
|
1470
|
+
else if (isSuppressedEntry(entry, this.suppressedTags)) {
|
|
1471
|
+
// suppressed tag (e.g. /mute-errors) — silently buffered, hidden from display
|
|
1472
|
+
}
|
|
1189
1473
|
else if (this.filterTag && !matchesTagFilter(entry, this.filterTag)) {
|
|
1190
1474
|
// tag filter active: silently skip non-matching entries
|
|
1191
1475
|
}
|
|
@@ -1225,20 +1509,26 @@ export class TUI {
|
|
|
1225
1509
|
this.activityBuffer = this.activityBuffer.slice(-this.maxActivity);
|
|
1226
1510
|
}
|
|
1227
1511
|
}
|
|
1512
|
+
/** Apply all active display filters to an entry array: mute, suppress, tag, search. */
|
|
1513
|
+
applyDisplayFilters(entries) {
|
|
1514
|
+
let out = entries;
|
|
1515
|
+
if (this.mutedIds.size > 0)
|
|
1516
|
+
out = out.filter((e) => !shouldMuteEntry(e, this.mutedIds));
|
|
1517
|
+
if (this.suppressedTags.size > 0)
|
|
1518
|
+
out = out.filter((e) => !isSuppressedEntry(e, this.suppressedTags));
|
|
1519
|
+
if (this.filterTag)
|
|
1520
|
+
out = out.filter((e) => matchesTagFilter(e, this.filterTag));
|
|
1521
|
+
if (this.searchPattern)
|
|
1522
|
+
out = out.filter((e) => matchesSearch(e, this.searchPattern));
|
|
1523
|
+
return out;
|
|
1524
|
+
}
|
|
1228
1525
|
// ── Scroll navigation ────────────────────────────────────────────────────
|
|
1229
1526
|
scrollUp(lines) {
|
|
1230
1527
|
if (!this.active)
|
|
1231
1528
|
return;
|
|
1232
1529
|
const visibleLines = this.scrollBottom - this.scrollTop + 1;
|
|
1233
1530
|
const n = lines ?? Math.max(1, Math.floor(visibleLines / 2));
|
|
1234
|
-
|
|
1235
|
-
? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
|
|
1236
|
-
: this.activityBuffer;
|
|
1237
|
-
if (this.filterTag)
|
|
1238
|
-
filtered = filtered.filter((e) => matchesTagFilter(e, this.filterTag));
|
|
1239
|
-
const entryCount = this.searchPattern
|
|
1240
|
-
? filtered.filter((e) => matchesSearch(e, this.searchPattern)).length
|
|
1241
|
-
: filtered.length;
|
|
1531
|
+
const entryCount = this.applyDisplayFilters(this.activityBuffer).length;
|
|
1242
1532
|
const maxOffset = Math.max(0, entryCount - visibleLines);
|
|
1243
1533
|
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + n);
|
|
1244
1534
|
this.repaintActivityRegion();
|
|
@@ -1260,14 +1550,7 @@ export class TUI {
|
|
|
1260
1550
|
if (!this.active)
|
|
1261
1551
|
return;
|
|
1262
1552
|
const visibleLines = this.scrollBottom - this.scrollTop + 1;
|
|
1263
|
-
|
|
1264
|
-
? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
|
|
1265
|
-
: this.activityBuffer;
|
|
1266
|
-
if (this.filterTag)
|
|
1267
|
-
filtered = filtered.filter((e) => matchesTagFilter(e, this.filterTag));
|
|
1268
|
-
const entryCount = this.searchPattern
|
|
1269
|
-
? filtered.filter((e) => matchesSearch(e, this.searchPattern)).length
|
|
1270
|
-
: filtered.length;
|
|
1553
|
+
const entryCount = this.applyDisplayFilters(this.activityBuffer).length;
|
|
1271
1554
|
this.scrollOffset = Math.max(0, entryCount - visibleLines);
|
|
1272
1555
|
this.repaintActivityRegion();
|
|
1273
1556
|
this.paintSeparator();
|
|
@@ -1510,7 +1793,12 @@ export class TUI {
|
|
|
1510
1793
|
}
|
|
1511
1794
|
// reasoner badge
|
|
1512
1795
|
const reasonerTag = this.reasonerName ? ` ${SLATE}│${RESET} ${TEAL}${this.reasonerName}${RESET}` : "";
|
|
1513
|
-
|
|
1796
|
+
// watchdog indicator — show threshold when active
|
|
1797
|
+
const wdMin = this.watchdogThresholdMs !== null ? Math.round(this.watchdogThresholdMs / 60_000) : null;
|
|
1798
|
+
const watchdogTag = wdMin !== null ? ` ${SLATE}│${RESET} ${AMBER}⊛${wdMin}m${RESET}` : "";
|
|
1799
|
+
// group filter indicator
|
|
1800
|
+
const groupFilterTag = this.groupFilter ? ` ${SLATE}│${RESET} ${TEAL}${GROUP_ICON}${this.groupFilter}${RESET}` : "";
|
|
1801
|
+
line = ` ${INDIGO}${BOLD}aoaoe${RESET} ${SLATE}${this.version}${RESET} ${SLATE}│${RESET} #${this.pollCount} ${SLATE}│${RESET} ${sessCount} ${SLATE}│${RESET} ${phaseText}${activeTag}${countdownTag}${watchdogTag}${groupFilterTag}${reasonerTag}`;
|
|
1514
1802
|
}
|
|
1515
1803
|
process.stderr.write(SAVE_CURSOR +
|
|
1516
1804
|
moveTo(1, 1) + CLEAR_LINE + BG_DARK + WHITE + truncateAnsi(line, this.cols) + padToWidth(line, this.cols) + RESET +
|
|
@@ -1567,7 +1855,11 @@ export class TUI {
|
|
|
1567
1855
|
watchdogThresholdMs: this.watchdogThresholdMs,
|
|
1568
1856
|
}));
|
|
1569
1857
|
}
|
|
1570
|
-
const
|
|
1858
|
+
const compactActivityRates = new Map();
|
|
1859
|
+
for (const s of visibleSessions) {
|
|
1860
|
+
compactActivityRates.set(s.id, computeSessionActivityRate(this.activityBuffer, this.activityTimestamps, s.id, nowMsCompact));
|
|
1861
|
+
}
|
|
1862
|
+
const compactRows = formatCompactRows(visibleSessions, innerWidth - 1, this.pinnedIds, this.mutedIds, noteIdSet, compactHealthScores, compactActivityRates);
|
|
1571
1863
|
for (let r = 0; r < compactRows.length; r++) {
|
|
1572
1864
|
const line = `${SLATE}${BOX.v}${RESET} ${compactRows[r]}`;
|
|
1573
1865
|
const padded = padBoxLine(line, this.cols);
|
|
@@ -1602,6 +1894,9 @@ export class TUI {
|
|
|
1602
1894
|
});
|
|
1603
1895
|
const healthBadge = formatHealthBadge(healthScore);
|
|
1604
1896
|
const displayName = this.sessionAliases.get(s.id);
|
|
1897
|
+
const sTags = this.sessionTags.get(s.id);
|
|
1898
|
+
const tagsBadge = sTags && sTags.size > 0 ? `${formatSessionTagsBadge(sTags)} ` : "";
|
|
1899
|
+
const tagsBadgeWidth = sTags && sTags.size > 0 ? stripAnsiForLen(formatSessionTagsBadge(sTags)) + 1 : 0;
|
|
1605
1900
|
const muteBadge = muted ? formatMuteBadge(this.mutedEntryCounts.get(s.id) ?? 0) : "";
|
|
1606
1901
|
const muteBadgeWidth = muted ? String(Math.min(this.mutedEntryCounts.get(s.id) ?? 0, 9999)).length + 2 : 0; // "(N)" visible chars, 0 when count is 0
|
|
1607
1902
|
const actualBadgeWidth = (this.mutedEntryCounts.get(s.id) ?? 0) > 0 ? muteBadgeWidth + 1 : 0; // +1 for trailing space
|
|
@@ -1611,9 +1906,9 @@ export class TUI {
|
|
|
1611
1906
|
const note = noted ? `${TEAL}${NOTE_ICON}${RESET} ` : "";
|
|
1612
1907
|
const groupBadge = group ? `${formatGroupBadge(group)} ` : "";
|
|
1613
1908
|
const badgeSuffix = muteBadge ? `${muteBadge} ` : "";
|
|
1614
|
-
const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth;
|
|
1909
|
+
const iconsWidth = (pinned ? 2 : 0) + (muted ? 2 : 0) + (noted ? 2 : 0) + actualBadgeWidth + groupBadgeWidth + tagsBadgeWidth;
|
|
1615
1910
|
const cardWidth = innerWidth - 1 - iconsWidth;
|
|
1616
|
-
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
|
|
1911
|
+
const line = `${bg}${SLATE}${BOX.v}${RESET}${bg} ${pin}${mute}${badgeSuffix}${note}${groupBadge}${tagsBadge}${formatSessionCard(s, cardWidth, errSparkline || undefined, idleSinceMs, healthBadge || undefined, displayName)}`;
|
|
1617
1912
|
const padded = padBoxLineHover(line, this.cols, isHovered);
|
|
1618
1913
|
process.stderr.write(moveTo(startRow + 1 + i, 1) + CLEAR_LINE + padded);
|
|
1619
1914
|
}
|
|
@@ -1684,13 +1979,14 @@ export class TUI {
|
|
|
1684
1979
|
paintSeparator() {
|
|
1685
1980
|
const prefix = `${BOX.h}${BOX.h} activity `;
|
|
1686
1981
|
let hints;
|
|
1982
|
+
// suppressed-errors indicator when active (shown before other filters)
|
|
1983
|
+
const suppressedSuffix = this.suppressedTags.size > 0
|
|
1984
|
+
? ` ${DIM}${MUTE_ICON}errors${RESET}` : "";
|
|
1687
1985
|
if (this.filterTag) {
|
|
1688
1986
|
// tag filter takes precedence in the separator display
|
|
1689
|
-
|
|
1690
|
-
? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
|
|
1691
|
-
: this.activityBuffer;
|
|
1987
|
+
const source = this.applyDisplayFilters(this.activityBuffer.filter((e) => !isSuppressedEntry(e, this.suppressedTags)));
|
|
1692
1988
|
const matchCount = source.filter((e) => matchesTagFilter(e, this.filterTag)).length;
|
|
1693
|
-
hints = formatTagFilterIndicator(this.filterTag, matchCount, source.length);
|
|
1989
|
+
hints = formatTagFilterIndicator(this.filterTag, matchCount, source.length) + suppressedSuffix;
|
|
1694
1990
|
}
|
|
1695
1991
|
else if (this.searchPattern) {
|
|
1696
1992
|
const filtered = this.activityBuffer.filter((e) => matchesSearch(e, this.searchPattern));
|
|
@@ -1722,15 +2018,8 @@ export class TUI {
|
|
|
1722
2018
|
}
|
|
1723
2019
|
repaintActivityRegion() {
|
|
1724
2020
|
const visibleLines = this.scrollBottom - this.scrollTop + 1;
|
|
1725
|
-
// filter pipeline: muted → tag → search
|
|
1726
|
-
|
|
1727
|
-
? this.activityBuffer.filter((e) => !shouldMuteEntry(e, this.mutedIds))
|
|
1728
|
-
: this.activityBuffer;
|
|
1729
|
-
if (this.filterTag)
|
|
1730
|
-
source = source.filter((e) => matchesTagFilter(e, this.filterTag));
|
|
1731
|
-
if (this.searchPattern) {
|
|
1732
|
-
source = source.filter((e) => matchesSearch(e, this.searchPattern));
|
|
1733
|
-
}
|
|
2021
|
+
// filter pipeline: muted → suppressed → tag → search
|
|
2022
|
+
const source = this.applyDisplayFilters(this.activityBuffer);
|
|
1734
2023
|
const { start, end } = computeScrollSlice(source.length, visibleLines, this.scrollOffset);
|
|
1735
2024
|
const entries = source.slice(start, end);
|
|
1736
2025
|
for (let i = 0; i < visibleLines; i++) {
|