aoaoe 0.63.0 → 0.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -248,6 +248,10 @@ commands:
248
248
  logs --actions show action log entries (from ~/.aoaoe/actions.log)
249
249
  logs --grep <pattern> filter log entries by substring or regex
250
250
  logs -n <count> number of entries to show (default: 50)
251
+ export export session timeline as JSON or Markdown for post-mortems
252
+ export --format <json|markdown> output format (default: json)
253
+ export --output <file> write to file (default: stdout)
254
+ export --last <duration> time window: 1h, 6h, 24h, 7d (default: 24h)
251
255
  task manage tasks and sessions (list, start, stop, new, rm, edit)
252
256
  tasks show task progress (from aoaoe.tasks.json)
253
257
  history review recent actions (from ~/.aoaoe/actions.log)
package/dist/config.d.ts CHANGED
@@ -34,6 +34,10 @@ export declare function parseCliArgs(argv: string[]): {
34
34
  logsActions: boolean;
35
35
  logsGrep?: string;
36
36
  logsCount?: number;
37
+ runExport: boolean;
38
+ exportFormat?: string;
39
+ exportOutput?: string;
40
+ exportLast?: string;
37
41
  runInit: boolean;
38
42
  initForce: boolean;
39
43
  runTaskCli: boolean;
package/dist/config.js CHANGED
@@ -327,7 +327,7 @@ export function parseCliArgs(argv) {
327
327
  let initForce = false;
328
328
  let runTaskCli = false;
329
329
  let registerTitle;
330
- const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runInit: false, initForce: false, runTaskCli: false };
330
+ const defaults = { overrides, help: false, version: false, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false };
331
331
  // check for subcommand as first non-flag arg
332
332
  if (argv[2] === "test-context") {
333
333
  return { ...defaults, testContext: true };
@@ -374,6 +374,23 @@ export function parseCliArgs(argv) {
374
374
  }
375
375
  return { ...defaults, runLogs: true, logsActions: actions, logsGrep: grep, logsCount: count };
376
376
  }
377
+ if (argv[2] === "export") {
378
+ let format;
379
+ let output;
380
+ let last;
381
+ for (let i = 3; i < argv.length; i++) {
382
+ if ((argv[i] === "--format" || argv[i] === "-f") && argv[i + 1]) {
383
+ format = argv[++i];
384
+ }
385
+ else if ((argv[i] === "--output" || argv[i] === "-o") && argv[i + 1]) {
386
+ output = argv[++i];
387
+ }
388
+ else if ((argv[i] === "--last" || argv[i] === "-l") && argv[i + 1]) {
389
+ last = argv[++i];
390
+ }
391
+ }
392
+ return { ...defaults, runExport: true, exportFormat: format, exportOutput: output, exportLast: last };
393
+ }
377
394
  if (argv[2] === "init") {
378
395
  const force = argv.includes("--force") || argv.includes("-f");
379
396
  return { ...defaults, runInit: true, initForce: force };
@@ -471,7 +488,7 @@ export function parseCliArgs(argv) {
471
488
  break;
472
489
  }
473
490
  }
474
- return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runInit: false, initForce: false, runTaskCli: false };
491
+ return { overrides, help, version, register: false, testContext: false, runTest: false, showTasks: false, showHistory: false, showStatus: false, showConfig: false, configValidate: false, configDiff: false, notifyTest: false, runDoctor: false, runLogs: false, logsActions: false, logsGrep: undefined, logsCount: undefined, runExport: false, exportFormat: undefined, exportOutput: undefined, exportLast: undefined, runInit: false, initForce: false, runTaskCli: false };
475
492
  }
476
493
  export function printHelp() {
477
494
  console.log(`aoaoe - autonomous supervisor for agent-of-empires sessions
@@ -497,6 +514,10 @@ commands:
497
514
  logs --actions show action log entries (from ~/.aoaoe/actions.log)
498
515
  logs --grep <pattern> filter log entries by substring or regex
499
516
  logs -n <count> number of entries to show (default: 50)
517
+ export export session timeline as JSON or Markdown for post-mortems
518
+ export --format <json|markdown> output format (default: json)
519
+ export --output <file> write to file (default: stdout)
520
+ export --last <duration> time window: 1h, 6h, 24h, 7d (default: 24h)
500
521
  task manage tasks and sessions (list, start, stop, new, rm, edit)
501
522
  tasks show task progress (from aoaoe.tasks.json)
502
523
  history review recent actions (from ~/.aoaoe/actions.log)
@@ -0,0 +1,17 @@
1
+ import type { HistoryEntry } from "./tui-history.js";
2
+ export interface TimelineEntry {
3
+ ts: number;
4
+ source: "action" | "activity";
5
+ tag: string;
6
+ text: string;
7
+ success?: boolean;
8
+ session?: string;
9
+ }
10
+ export declare function parseActionLogEntries(lines: string[]): TimelineEntry[];
11
+ export declare function parseActivityEntries(entries: HistoryEntry[]): TimelineEntry[];
12
+ export declare function mergeTimeline(...sources: TimelineEntry[][]): TimelineEntry[];
13
+ export declare function filterByAge(entries: TimelineEntry[], maxAgeMs: number, now?: number): TimelineEntry[];
14
+ export declare function parseDuration(input: string): number | null;
15
+ export declare function formatTimelineJson(entries: TimelineEntry[]): string;
16
+ export declare function formatTimelineMarkdown(entries: TimelineEntry[]): string;
17
+ //# sourceMappingURL=export.d.ts.map
package/dist/export.js ADDED
@@ -0,0 +1,132 @@
1
+ // export.ts — pure functions for exporting session timelines as JSON or Markdown
2
+ // reads actions.log (JSONL) and tui-history.jsonl, merges into a unified timeline
3
+ import { toActionLogEntry } from "./types.js";
4
+ // ── parsers ─────────────────────────────────────────────────────────────────
5
+ // parse actions.log JSONL lines into timeline entries
6
+ export function parseActionLogEntries(lines) {
7
+ const entries = [];
8
+ for (const line of lines) {
9
+ if (!line.trim())
10
+ continue;
11
+ try {
12
+ const entry = toActionLogEntry(JSON.parse(line));
13
+ if (!entry)
14
+ continue;
15
+ // skip wait actions (noise in post-mortems)
16
+ if (entry.action.action === "wait")
17
+ continue;
18
+ entries.push({
19
+ ts: entry.timestamp,
20
+ source: "action",
21
+ tag: entry.action.action,
22
+ text: entry.detail || `${entry.action.action} on ${entry.action.session ?? "unknown"}`,
23
+ success: entry.success,
24
+ session: entry.action.session ?? entry.action.title,
25
+ });
26
+ }
27
+ catch {
28
+ // skip malformed lines
29
+ }
30
+ }
31
+ return entries;
32
+ }
33
+ // convert tui-history entries into timeline entries
34
+ export function parseActivityEntries(entries) {
35
+ return entries.map((e) => ({
36
+ ts: e.ts,
37
+ source: "activity",
38
+ tag: e.tag,
39
+ text: e.text,
40
+ }));
41
+ }
42
+ // ── merge + filter ──────────────────────────────────────────────────────────
43
+ // merge multiple sources into a single chronological timeline
44
+ export function mergeTimeline(...sources) {
45
+ const all = sources.flat();
46
+ all.sort((a, b) => a.ts - b.ts);
47
+ return all;
48
+ }
49
+ // filter entries by time window (keep entries newer than cutoff)
50
+ export function filterByAge(entries, maxAgeMs, now = Date.now()) {
51
+ const cutoff = now - maxAgeMs;
52
+ return entries.filter((e) => e.ts >= cutoff);
53
+ }
54
+ // ── duration parser ─────────────────────────────────────────────────────────
55
+ // parse human-friendly duration strings: "1h", "6h", "24h", "2d", "7d", "30d"
56
+ export function parseDuration(input) {
57
+ const match = input.match(/^(\d+)(h|d)$/);
58
+ if (!match)
59
+ return null;
60
+ const val = parseInt(match[1], 10);
61
+ if (val <= 0)
62
+ return null;
63
+ const unit = match[2];
64
+ if (unit === "h")
65
+ return val * 60 * 60 * 1000;
66
+ if (unit === "d")
67
+ return val * 24 * 60 * 60 * 1000;
68
+ return null;
69
+ }
70
+ // ── formatters ──────────────────────────────────────────────────────────────
71
+ // format timeline as pretty-printed JSON
72
+ export function formatTimelineJson(entries) {
73
+ const output = entries.map((e) => {
74
+ const obj = {
75
+ time: new Date(e.ts).toISOString(),
76
+ source: e.source,
77
+ tag: e.tag,
78
+ text: e.text,
79
+ };
80
+ if (e.success !== undefined)
81
+ obj.success = e.success;
82
+ if (e.session)
83
+ obj.session = e.session;
84
+ return obj;
85
+ });
86
+ return JSON.stringify(output, null, 2) + "\n";
87
+ }
88
+ // format timeline as a readable Markdown post-mortem document
89
+ export function formatTimelineMarkdown(entries) {
90
+ if (entries.length === 0) {
91
+ return "# aoaoe Session Export\n\n_No entries in the selected time range._\n";
92
+ }
93
+ const lines = [];
94
+ const now = new Date();
95
+ const first = new Date(entries[0].ts);
96
+ const last = new Date(entries[entries.length - 1].ts);
97
+ lines.push("# aoaoe Session Export");
98
+ lines.push("");
99
+ lines.push(`**Generated**: ${now.toISOString()}`);
100
+ lines.push(`**Range**: ${first.toISOString()} — ${last.toISOString()}`);
101
+ lines.push(`**Entries**: ${entries.length}`);
102
+ lines.push("");
103
+ lines.push("## Timeline");
104
+ lines.push("");
105
+ // group entries by hour
106
+ let currentHour = "";
107
+ for (const entry of entries) {
108
+ const d = new Date(entry.ts);
109
+ const hour = d.toLocaleString("en-US", {
110
+ month: "short", day: "numeric", hour: "numeric", minute: undefined, hour12: true,
111
+ }).replace(/:00/, "");
112
+ // simpler: just use HH:00 block headers
113
+ const hourKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:00`;
114
+ if (hourKey !== currentHour) {
115
+ if (currentHour)
116
+ lines.push("");
117
+ lines.push(`### ${hourKey}`);
118
+ lines.push("");
119
+ currentHour = hourKey;
120
+ }
121
+ const time = `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
122
+ const icon = entry.source === "action"
123
+ ? (entry.success ? "+" : "!")
124
+ : "·";
125
+ const sessionPart = entry.session ? ` → ${entry.session}` : "";
126
+ const text = entry.text.length > 120 ? entry.text.slice(0, 117) + "..." : entry.text;
127
+ lines.push(`- \`${time}\` ${icon} **${entry.tag}**${sessionPart}: ${text}`);
128
+ }
129
+ lines.push("");
130
+ return lines.join("\n");
131
+ }
132
+ //# sourceMappingURL=export.js.map
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { isDaemonRunningFromState } from "./chat.js";
20
20
  import { sendNotification, sendTestNotification } from "./notify.js";
21
21
  import { startHealthServer } from "./health.js";
22
22
  import { loadTuiHistory } from "./tui-history.js";
23
+ import { parseActionLogEntries, parseActivityEntries, mergeTimeline, filterByAge, parseDuration, formatTimelineJson, formatTimelineMarkdown } from "./export.js";
23
24
  import { actionSession, actionDetail, toActionLogEntry } from "./types.js";
24
25
  import { YELLOW, GREEN, DIM, BOLD, RED, RESET } from "./colors.js";
25
26
  import { readFileSync, existsSync, statSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
@@ -30,7 +31,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
31
  const AOAOE_DIR = join(homedir(), ".aoaoe"); // watch dir for wakeable sleep
31
32
  const INPUT_FILE = join(AOAOE_DIR, "pending-input.txt"); // file IPC from chat.ts
32
33
  async function main() {
33
- const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, configValidate, configDiff, notifyTest, runDoctor, runLogs, logsActions, logsGrep, logsCount, runInit, initForce, runTaskCli: isTaskCli, registerTitle } = parseCliArgs(process.argv);
34
+ const { overrides, help, version, register, testContext: isTestContext, runTest, showTasks, showHistory, showStatus, showConfig, configValidate, configDiff, notifyTest, runDoctor, runLogs, logsActions, logsGrep, logsCount, runExport, exportFormat, exportOutput, exportLast, runInit, initForce, runTaskCli: isTaskCli, registerTitle } = parseCliArgs(process.argv);
34
35
  if (help) {
35
36
  printHelp();
36
37
  process.exit(0);
@@ -103,6 +104,11 @@ async function main() {
103
104
  await showLogs(logsActions, logsGrep, logsCount);
104
105
  return;
105
106
  }
107
+ // `aoaoe export` -- export session timeline as JSON or Markdown
108
+ if (runExport) {
109
+ await runTimelineExport(exportFormat, exportOutput, exportLast);
110
+ return;
111
+ }
106
112
  // `aoaoe task` -- task management CLI
107
113
  if (isTaskCli) {
108
114
  await runTaskCli(process.argv);
@@ -229,6 +235,25 @@ async function main() {
229
235
  // start interactive input listener and conversation log
230
236
  input.start();
231
237
  await reasonerConsole.start();
238
+ // wire scroll keys to TUI (PgUp/PgDn/Home/End)
239
+ if (tui) {
240
+ input.onScroll((dir) => {
241
+ switch (dir) {
242
+ case "up":
243
+ tui.scrollUp();
244
+ break;
245
+ case "down":
246
+ tui.scrollDown();
247
+ break;
248
+ case "top":
249
+ tui.scrollToTop();
250
+ break;
251
+ case "bottom":
252
+ tui.scrollToBottom();
253
+ break;
254
+ }
255
+ });
256
+ }
232
257
  // start TUI (alternate screen buffer) after input is ready
233
258
  if (tui) {
234
259
  // replay persisted history from previous runs before entering alt screen
@@ -1311,6 +1336,47 @@ async function showLogs(actions, grep, count) {
1311
1336
  console.log("");
1312
1337
  }
1313
1338
  }
1339
+ // `aoaoe export` -- export session timeline as JSON or Markdown for post-mortems
1340
+ async function runTimelineExport(format, output, last) {
1341
+ const fmt = format ?? "json";
1342
+ if (fmt !== "json" && fmt !== "markdown" && fmt !== "md") {
1343
+ console.error(`error: --format must be "json" or "markdown", got "${fmt}"`);
1344
+ process.exit(1);
1345
+ }
1346
+ // parse time window (default 24h)
1347
+ const durationMs = last ? parseDuration(last) : 24 * 60 * 60 * 1000;
1348
+ if (durationMs === null) {
1349
+ console.error(`error: --last must be like "1h", "6h", "24h", "7d", got "${last}"`);
1350
+ process.exit(1);
1351
+ }
1352
+ // read actions.log
1353
+ const actionsFile = join(homedir(), ".aoaoe", "actions.log");
1354
+ let actionEntries = [];
1355
+ try {
1356
+ const lines = readFileSync(actionsFile, "utf-8").trim().split("\n").filter((l) => l.trim());
1357
+ actionEntries = parseActionLogEntries(lines);
1358
+ }
1359
+ catch {
1360
+ // no actions.log — that's fine
1361
+ }
1362
+ // read tui-history.jsonl
1363
+ const historyEntries = loadTuiHistory(10_000, undefined, durationMs);
1364
+ const activityEntries = parseActivityEntries(historyEntries);
1365
+ // merge and filter
1366
+ let timeline = mergeTimeline(actionEntries, activityEntries);
1367
+ timeline = filterByAge(timeline, durationMs);
1368
+ // format
1369
+ const isMarkdown = fmt === "markdown" || fmt === "md";
1370
+ const content = isMarkdown ? formatTimelineMarkdown(timeline) : formatTimelineJson(timeline);
1371
+ // output
1372
+ if (output) {
1373
+ writeFileSync(output, content);
1374
+ console.log(`exported ${timeline.length} entries to ${output}`);
1375
+ }
1376
+ else {
1377
+ process.stdout.write(content);
1378
+ }
1379
+ }
1314
1380
  // `aoaoe test` -- dynamically import and run the integration test
1315
1381
  async function runIntegrationTest() {
1316
1382
  const testModule = resolve(__dirname, "integration-test.js");
package/dist/input.d.ts CHANGED
@@ -1,8 +1,11 @@
1
+ export type ScrollDirection = "up" | "down" | "top" | "bottom";
1
2
  export declare class InputReader {
2
3
  private rl;
3
4
  private queue;
4
5
  private paused;
5
6
  private lastEscTime;
7
+ private scrollHandler;
8
+ onScroll(handler: (dir: ScrollDirection) => void): void;
6
9
  start(): void;
7
10
  drain(): string[];
8
11
  isPaused(): boolean;
package/dist/input.js CHANGED
@@ -11,6 +11,11 @@ export class InputReader {
11
11
  queue = []; // pending user messages for the reasoner
12
12
  paused = false;
13
13
  lastEscTime = 0;
14
+ scrollHandler = null;
15
+ // register a callback for scroll key events (PgUp/PgDn/Home/End)
16
+ onScroll(handler) {
17
+ this.scrollHandler = handler;
18
+ }
14
19
  start() {
15
20
  // only works if stdin is a TTY (not piped)
16
21
  if (!process.stdin.isTTY)
@@ -39,6 +44,21 @@ export class InputReader {
39
44
  else {
40
45
  this.lastEscTime = 0;
41
46
  }
47
+ // scroll key detection (PgUp, PgDn, Home, End)
48
+ if (this.scrollHandler) {
49
+ if (key?.name === "pageup" || key?.sequence === "\x1b[5~") {
50
+ this.scrollHandler("up");
51
+ }
52
+ else if (key?.name === "pagedown" || key?.sequence === "\x1b[6~") {
53
+ this.scrollHandler("down");
54
+ }
55
+ else if (key?.name === "home" || key?.sequence === "\x1b[1~") {
56
+ this.scrollHandler("top");
57
+ }
58
+ else if (key?.name === "end" || key?.sequence === "\x1b[4~") {
59
+ this.scrollHandler("bottom");
60
+ }
61
+ }
42
62
  });
43
63
  // show hint on startup
44
64
  console.error(`${DIM}type a message to talk to the AI supervisor, /help for commands, ESC ESC to interrupt${RESET}`);
@@ -105,6 +125,8 @@ ${BOLD}controls:${RESET}
105
125
  /resume resume the supervisor
106
126
  /interrupt interrupt the AI mid-thought
107
127
  ESC ESC same as /interrupt (shortcut)
128
+ PgUp / PgDn scroll through activity history
129
+ Home / End jump to oldest / return to live
108
130
 
109
131
  ${BOLD}info:${RESET}
110
132
  /status show daemon state
package/dist/tui.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare class TUI {
20
20
  private activityBuffer;
21
21
  private maxActivity;
22
22
  private spinnerFrame;
23
+ private scrollOffset;
24
+ private newWhileScrolled;
23
25
  private phase;
24
26
  private pollCount;
25
27
  private sessions;
@@ -40,6 +42,11 @@ export declare class TUI {
40
42
  }): void;
41
43
  log(tag: string, text: string): void;
42
44
  replayHistory(entries: HistoryEntry[]): void;
45
+ scrollUp(lines?: number): void;
46
+ scrollDown(lines?: number): void;
47
+ scrollToTop(): void;
48
+ scrollToBottom(): void;
49
+ isScrolledBack(): boolean;
43
50
  private updateDimensions;
44
51
  private computeLayout;
45
52
  private onResize;
@@ -63,5 +70,10 @@ declare function truncatePlain(str: string, max: number): string;
63
70
  * Kept for backward compatibility — used by non-TUI output paths.
64
71
  */
65
72
  export declare function formatSessionSentence(s: DaemonSessionState, maxCols: number): string;
66
- export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay };
73
+ declare function computeScrollSlice(bufferLen: number, visibleLines: number, scrollOffset: number): {
74
+ start: number;
75
+ end: number;
76
+ };
77
+ declare function formatScrollIndicator(offset: number, totalEntries: number, visibleLines: number, newCount: number): string;
78
+ export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay, computeScrollSlice, formatScrollIndicator };
67
79
  //# sourceMappingURL=tui.d.ts.map
package/dist/tui.js CHANGED
@@ -55,6 +55,8 @@ export class TUI {
55
55
  activityBuffer = []; // ring buffer for activity log
56
56
  maxActivity = 500; // max entries to keep
57
57
  spinnerFrame = 0; // current spinner animation frame
58
+ scrollOffset = 0; // 0 = live (bottom), >0 = scrolled back N entries
59
+ newWhileScrolled = 0; // entries added while user is scrolled back
58
60
  // current state for repaints
59
61
  phase = "sleeping";
60
62
  pollCount = 0;
@@ -139,8 +141,16 @@ export class TUI {
139
141
  if (this.activityBuffer.length > this.maxActivity) {
140
142
  this.activityBuffer = this.activityBuffer.slice(-this.maxActivity);
141
143
  }
142
- if (this.active)
143
- this.writeActivityLine(entry);
144
+ if (this.active) {
145
+ if (this.scrollOffset > 0) {
146
+ // user is scrolled back — don't auto-scroll, just show indicator
147
+ this.newWhileScrolled++;
148
+ this.paintSeparator();
149
+ }
150
+ else {
151
+ this.writeActivityLine(entry);
152
+ }
153
+ }
144
154
  // persist to disk (fire-and-forget, never blocks)
145
155
  appendHistoryEntry({ ts: now.getTime(), time, tag, text });
146
156
  }
@@ -155,6 +165,48 @@ export class TUI {
155
165
  this.activityBuffer = this.activityBuffer.slice(-this.maxActivity);
156
166
  }
157
167
  }
168
+ // ── Scroll navigation ────────────────────────────────────────────────────
169
+ scrollUp(lines) {
170
+ if (!this.active)
171
+ return;
172
+ const visibleLines = this.scrollBottom - this.scrollTop + 1;
173
+ const n = lines ?? Math.max(1, Math.floor(visibleLines / 2));
174
+ const maxOffset = Math.max(0, this.activityBuffer.length - visibleLines);
175
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + n);
176
+ this.repaintActivityRegion();
177
+ this.paintSeparator();
178
+ }
179
+ scrollDown(lines) {
180
+ if (!this.active)
181
+ return;
182
+ const visibleLines = this.scrollBottom - this.scrollTop + 1;
183
+ const n = lines ?? Math.max(1, Math.floor(visibleLines / 2));
184
+ const wasScrolled = this.scrollOffset > 0;
185
+ this.scrollOffset = Math.max(0, this.scrollOffset - n);
186
+ if (wasScrolled && this.scrollOffset === 0)
187
+ this.newWhileScrolled = 0;
188
+ this.repaintActivityRegion();
189
+ this.paintSeparator();
190
+ }
191
+ scrollToTop() {
192
+ if (!this.active)
193
+ return;
194
+ const visibleLines = this.scrollBottom - this.scrollTop + 1;
195
+ this.scrollOffset = Math.max(0, this.activityBuffer.length - visibleLines);
196
+ this.repaintActivityRegion();
197
+ this.paintSeparator();
198
+ }
199
+ scrollToBottom() {
200
+ if (!this.active)
201
+ return;
202
+ this.scrollOffset = 0;
203
+ this.newWhileScrolled = 0;
204
+ this.repaintActivityRegion();
205
+ this.paintSeparator();
206
+ }
207
+ isScrolledBack() {
208
+ return this.scrollOffset > 0;
209
+ }
158
210
  // ── Layout computation ──────────────────────────────────────────────────
159
211
  updateDimensions() {
160
212
  this.cols = process.stderr.columns || 80;
@@ -245,8 +297,14 @@ export class TUI {
245
297
  process.stderr.write(RESTORE_CURSOR);
246
298
  }
247
299
  paintSeparator() {
248
- const hints = " esc esc: interrupt /help /explain /pause ";
249
300
  const prefix = `${BOX.h}${BOX.h} activity `;
301
+ let hints;
302
+ if (this.scrollOffset > 0) {
303
+ hints = formatScrollIndicator(this.scrollOffset, this.activityBuffer.length, this.scrollBottom - this.scrollTop + 1, this.newWhileScrolled);
304
+ }
305
+ else {
306
+ hints = " esc esc: interrupt /help /explain /pause ";
307
+ }
250
308
  const totalLen = prefix.length + hints.length;
251
309
  const fill = Math.max(0, this.cols - totalLen);
252
310
  const left = Math.floor(fill / 2);
@@ -265,7 +323,8 @@ export class TUI {
265
323
  }
266
324
  repaintActivityRegion() {
267
325
  const visibleLines = this.scrollBottom - this.scrollTop + 1;
268
- const entries = this.activityBuffer.slice(-visibleLines);
326
+ const { start, end } = computeScrollSlice(this.activityBuffer.length, visibleLines, this.scrollOffset);
327
+ const entries = this.activityBuffer.slice(start, end);
269
328
  for (let i = 0; i < visibleLines; i++) {
270
329
  const row = this.scrollTop + i;
271
330
  if (i < entries.length) {
@@ -431,6 +490,19 @@ export function formatSessionSentence(s, maxCols) {
431
490
  }
432
491
  return truncateAnsi(`${dot} ${BOLD}${name}${RESET} ${tool} ${SLATE}—${RESET} ${statusDesc}`, maxCols);
433
492
  }
493
+ // ── Scroll helpers (pure, exported for testing) ─────────────────────────────
494
+ // compute the slice indices for the activity buffer given scroll state
495
+ function computeScrollSlice(bufferLen, visibleLines, scrollOffset) {
496
+ const end = Math.max(0, bufferLen - scrollOffset);
497
+ const start = Math.max(0, end - visibleLines);
498
+ return { start, end };
499
+ }
500
+ // format the scroll indicator text for the separator bar
501
+ function formatScrollIndicator(offset, totalEntries, visibleLines, newCount) {
502
+ const position = totalEntries - offset;
503
+ const newTag = newCount > 0 ? ` ${newCount} new ↓` : "";
504
+ return ` ↑ ${offset} older │ ${position}/${totalEntries} │ PgUp/PgDn End=live${newTag} `;
505
+ }
434
506
  // ── Exported pure helpers (for testing) ─────────────────────────────────────
435
- export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay };
507
+ export { formatActivity, formatSessionCard, truncateAnsi, truncatePlain, padBoxLine, padToWidth, stripAnsiForLen, phaseDisplay, computeScrollSlice, formatScrollIndicator };
436
508
  //# sourceMappingURL=tui.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aoaoe",
3
- "version": "0.63.0",
3
+ "version": "0.65.0",
4
4
  "description": "Autonomous supervisor for agent-of-empires sessions using OpenCode or Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",