@addai/node 0.4.0 → 0.5.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.
@@ -4,6 +4,26 @@ export declare function visibleWidth(s: string): number;
4
4
  /** Cut to `max` VISIBLE columns, ellipsis included in the budget. */
5
5
  export declare function truncate(s: string, max: number): string;
6
6
  export declare function padEndVisible(s: string, width: number): string;
7
+ export declare function padStartVisible(s: string, width: number): string;
8
+ /** A column that takes what it needs, or grows to absorb what's spare. */
9
+ export interface ColumnSpec {
10
+ /** Columns narrower than this are unreadable, so they are never cut below it. */
11
+ min: number;
12
+ /** Share of the leftover width. 0 (the default) means fixed at `min`. */
13
+ grow?: number;
14
+ }
15
+ /**
16
+ * Split `width` across columns.
17
+ *
18
+ * Fixed columns are served first and always get their `min`. Whatever is left
19
+ * over after the gaps is handed to the growing columns in proportion to their
20
+ * `grow` — so a table clips the prompt, never the version or the account.
21
+ * When the terminal is too narrow for even the minimums, every column shrinks
22
+ * proportionally rather than the last one falling off the edge.
23
+ */
24
+ export declare function layoutColumns(specs: ColumnSpec[], width: number, gap?: number): number[];
25
+ /** Lay cells into `widths`, truncating and padding each to fit exactly. */
26
+ export declare function tableRow(cells: string[], widths: number[], align?: Array<'left' | 'right'>, gap?: number): string;
7
27
  export declare const dim: (s: string) => string;
8
28
  export declare const bold: (s: string) => string;
9
29
  export declare const green: (s: string) => string;
@@ -19,3 +39,23 @@ export declare function fmtDuration(ms: number | null): string;
19
39
  export declare const altOn: () => void;
20
40
  export declare const altOff: () => void;
21
41
  export declare const home: () => void;
42
+ export declare function termHeight(): number;
43
+ export interface Painter {
44
+ /** Write only what changed since the last frame. */
45
+ paint(lines: string[]): void;
46
+ /** Force the next paint to clear and redraw everything (resize, resume). */
47
+ invalidate(): void;
48
+ }
49
+ /**
50
+ * Frames are painted as a diff against the last one.
51
+ *
52
+ * The screen used to be erased (`ESC[2J`) before every frame, and with the
53
+ * spinner redrawing eight times a second while work is in flight, that is a
54
+ * full clear-and-repaint eight times a second — the flicker you can see from
55
+ * across the room. Almost nothing changes between those frames: a spinner
56
+ * glyph and a duration. So keep the last frame, compare line by line, and
57
+ * touch only the rows that actually moved.
58
+ *
59
+ * `write` is injected so the painter can be unit-tested without a TTY.
60
+ */
61
+ export declare function createPainter(write: (s: string) => void): Painter;
@@ -11,10 +11,15 @@ exports.stripAnsi = stripAnsi;
11
11
  exports.visibleWidth = visibleWidth;
12
12
  exports.truncate = truncate;
13
13
  exports.padEndVisible = padEndVisible;
14
+ exports.padStartVisible = padStartVisible;
15
+ exports.layoutColumns = layoutColumns;
16
+ exports.tableRow = tableRow;
14
17
  exports.termWidth = termWidth;
15
18
  exports.panel = panel;
16
19
  exports.fmtRelative = fmtRelative;
17
20
  exports.fmtDuration = fmtDuration;
21
+ exports.termHeight = termHeight;
22
+ exports.createPainter = createPainter;
18
23
  const E = '\x1b[';
19
24
  exports.SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
20
25
  /** Not global: a /g regex carries lastIndex between calls and would make
@@ -59,6 +64,58 @@ function padEndVisible(s, width) {
59
64
  const pad = Math.max(0, width - visibleWidth(s));
60
65
  return s + ' '.repeat(pad);
61
66
  }
67
+ function padStartVisible(s, width) {
68
+ const pad = Math.max(0, width - visibleWidth(s));
69
+ return ' '.repeat(pad) + s;
70
+ }
71
+ /**
72
+ * Split `width` across columns.
73
+ *
74
+ * Fixed columns are served first and always get their `min`. Whatever is left
75
+ * over after the gaps is handed to the growing columns in proportion to their
76
+ * `grow` — so a table clips the prompt, never the version or the account.
77
+ * When the terminal is too narrow for even the minimums, every column shrinks
78
+ * proportionally rather than the last one falling off the edge.
79
+ */
80
+ function layoutColumns(specs, width, gap = 1) {
81
+ if (specs.length === 0)
82
+ return [];
83
+ const gaps = gap * (specs.length - 1);
84
+ const mins = specs.map(s => Math.max(0, s.min));
85
+ const totalMin = mins.reduce((a, b) => a + b, 0);
86
+ const room = Math.max(0, width - gaps);
87
+ if (totalMin > room) {
88
+ const scale = room / (totalMin || 1);
89
+ return mins.map(m => Math.max(1, Math.floor(m * scale)));
90
+ }
91
+ const totalGrow = specs.reduce((a, s) => a + (s.grow ?? 0), 0);
92
+ let spare = room - totalMin;
93
+ if (totalGrow === 0 || spare === 0)
94
+ return mins;
95
+ // The last growing column takes the remainder, so rounding never loses a
96
+ // column of width off the right-hand edge.
97
+ let lastGrowing = -1;
98
+ specs.forEach((s, i) => { if ((s.grow ?? 0) > 0)
99
+ lastGrowing = i; });
100
+ return mins.map((m, i) => {
101
+ const grow = specs[i].grow ?? 0;
102
+ if (grow === 0)
103
+ return m;
104
+ const share = i === lastGrowing ? spare : Math.floor((spare * grow) / totalGrow);
105
+ spare -= share;
106
+ return m + share;
107
+ });
108
+ }
109
+ /** Lay cells into `widths`, truncating and padding each to fit exactly. */
110
+ function tableRow(cells, widths, align = [], gap = 1) {
111
+ return cells
112
+ .map((c, i) => {
113
+ const w = widths[i] ?? 0;
114
+ const cut = truncate(c, w);
115
+ return align[i] === 'right' ? padStartVisible(cut, w) : padEndVisible(cut, w);
116
+ })
117
+ .join(' '.repeat(gap));
118
+ }
62
119
  const wrapCode = (on, off) => (s) => `${E}${on}${s}${E}${off}`;
63
120
  exports.dim = wrapCode('2m', '22m');
64
121
  exports.bold = wrapCode('1m', '22m');
@@ -114,3 +171,45 @@ const altOff = () => { process.stdout.write(`${E}?1049l${E}?25h`); };
114
171
  exports.altOff = altOff;
115
172
  const home = () => { process.stdout.write(`${E}H${E}2J`); };
116
173
  exports.home = home;
174
+ function termHeight() {
175
+ return Math.max(10, process.stdout.rows || 30);
176
+ }
177
+ /**
178
+ * Frames are painted as a diff against the last one.
179
+ *
180
+ * The screen used to be erased (`ESC[2J`) before every frame, and with the
181
+ * spinner redrawing eight times a second while work is in flight, that is a
182
+ * full clear-and-repaint eight times a second — the flicker you can see from
183
+ * across the room. Almost nothing changes between those frames: a spinner
184
+ * glyph and a duration. So keep the last frame, compare line by line, and
185
+ * touch only the rows that actually moved.
186
+ *
187
+ * `write` is injected so the painter can be unit-tested without a TTY.
188
+ */
189
+ function createPainter(write) {
190
+ let prev = [];
191
+ let full = true;
192
+ return {
193
+ invalidate() { full = true; },
194
+ paint(lines) {
195
+ let out = '';
196
+ if (full) {
197
+ out += `${E}H${E}2J`;
198
+ prev = [];
199
+ full = false;
200
+ }
201
+ const rows = Math.max(lines.length, prev.length);
202
+ for (let i = 0; i < rows; i++) {
203
+ const next = lines[i] ?? '';
204
+ if (prev[i] === next)
205
+ continue;
206
+ // Position, write, and erase whatever the previous, longer line left
207
+ // behind on that row.
208
+ out += `${E}${i + 1};1H${next}${E}K`;
209
+ }
210
+ prev = lines.slice();
211
+ if (out)
212
+ write(out);
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,29 @@
1
+ import type { RequestRow } from './data';
2
+ export declare const ACTIVE: Set<string>;
3
+ export declare const CANCELLED: Set<string>;
4
+ /**
5
+ * Trigger sources are DB-shaped ('self_improve_distil', 'tables_trigger');
6
+ * the column is nine columns wide and 'self_impr' tells you nothing.
7
+ *
8
+ * Anything unmapped is shortened by rule rather than cut mid-word: the
9
+ * 'entity_' prefix and the '_trigger' suffix carry no information a reader of
10
+ * this column needs.
11
+ */
12
+ export declare function shortVia(via: string | null | undefined): string;
13
+ export declare function statusLabel(status: string): string;
14
+ export declare function statusColour(status: string): (s: string) => string;
15
+ /** Live rows get a moving spinner; settled rows get a dot. */
16
+ export declare function marker(status: string, spin: number): string;
17
+ export declare function durationOf(r: RequestRow, now: number): number | null;
18
+ /**
19
+ * Column widths for the request table.
20
+ *
21
+ * The two columns that carry names — the entity and the prompt — grow into
22
+ * whatever the terminal has spare, and the prompt takes the larger share
23
+ * because it is the only free text on the row. Everything else is fixed at
24
+ * the width its values actually need, so a version or a duration is never
25
+ * the thing that gets cut.
26
+ */
27
+ export declare function requestColumns(width: number): number[];
28
+ export declare function requestHeader(width: number): string;
29
+ export declare function requestLine(r: RequestRow, now: number, selected: boolean, width: number, spin?: number): string;
@@ -0,0 +1,133 @@
1
+ "use strict";
2
+ // One request, rendered as a row — shared by the cockpit's NOW band and the
3
+ // Activity screen so the two never drift apart.
4
+ //
5
+ // Status vocabulary comes from the DB, where terminal success is 'completed'
6
+ // and 'canceled' is a deliberate user action — shown apart from failures so a
7
+ // healthy node isn't slandered by its own cancels.
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.CANCELLED = exports.ACTIVE = void 0;
10
+ exports.shortVia = shortVia;
11
+ exports.statusLabel = statusLabel;
12
+ exports.statusColour = statusColour;
13
+ exports.marker = marker;
14
+ exports.durationOf = durationOf;
15
+ exports.requestColumns = requestColumns;
16
+ exports.requestHeader = requestHeader;
17
+ exports.requestLine = requestLine;
18
+ const render_1 = require("./render");
19
+ exports.ACTIVE = new Set(['pending', 'starting', 'running']);
20
+ exports.CANCELLED = new Set(['canceled', 'cancelled']);
21
+ /**
22
+ * Trigger sources are DB-shaped ('self_improve_distil', 'tables_trigger');
23
+ * the column is nine columns wide and 'self_impr' tells you nothing.
24
+ *
25
+ * Anything unmapped is shortened by rule rather than cut mid-word: the
26
+ * 'entity_' prefix and the '_trigger' suffix carry no information a reader of
27
+ * this column needs.
28
+ */
29
+ function shortVia(via) {
30
+ if (!via)
31
+ return '—';
32
+ const map = {
33
+ self_improve_distil: 'distil',
34
+ self_improve: 'improve',
35
+ entity_chat: 'chat',
36
+ chat: 'chat',
37
+ chatflows: 'chatflow',
38
+ entity_schedule: 'schedule',
39
+ schedule: 'schedule',
40
+ tables_trigger: 'tables',
41
+ skillflow: 'skillflow',
42
+ flow: 'flow',
43
+ api: 'api',
44
+ studio: 'studio',
45
+ widget: 'widget',
46
+ };
47
+ if (map[via])
48
+ return map[via];
49
+ return via.replace(/^entity_/, '').replace(/_trigger$/, '');
50
+ }
51
+ function statusLabel(status) {
52
+ if (status === 'completed')
53
+ return 'DONE';
54
+ if (exports.CANCELLED.has(status))
55
+ return 'CANCEL';
56
+ if (status === 'dead_letter')
57
+ return 'DEAD';
58
+ return status.toUpperCase();
59
+ }
60
+ function statusColour(status) {
61
+ if (exports.ACTIVE.has(status))
62
+ return render_1.cyan;
63
+ if (status === 'completed')
64
+ return render_1.green;
65
+ if (exports.CANCELLED.has(status))
66
+ return render_1.grey;
67
+ return render_1.red;
68
+ }
69
+ /** Live rows get a moving spinner; settled rows get a dot. */
70
+ function marker(status, spin) {
71
+ const colour = statusColour(status);
72
+ return colour(exports.ACTIVE.has(status) ? render_1.SPIN[spin % render_1.SPIN.length] : '⏺');
73
+ }
74
+ function durationOf(r, now) {
75
+ const start = r.picked_up_at ?? r.created_at;
76
+ if (!start)
77
+ return null;
78
+ const end = r.finished_at ? new Date(r.finished_at).getTime() : now;
79
+ return Math.max(0, end - new Date(start).getTime());
80
+ }
81
+ /**
82
+ * Column widths for the request table.
83
+ *
84
+ * The two columns that carry names — the entity and the prompt — grow into
85
+ * whatever the terminal has spare, and the prompt takes the larger share
86
+ * because it is the only free text on the row. Everything else is fixed at
87
+ * the width its values actually need, so a version or a duration is never
88
+ * the thing that gets cut.
89
+ */
90
+ function requestColumns(width) {
91
+ // marker, status, entity, agent/mode, model, via, duration, prompt
92
+ return (0, render_1.layoutColumns)([
93
+ { min: 1 },
94
+ // STARTING is the longest label; anything shorter than it clips the
95
+ // status, which is the one column that must never be ambiguous.
96
+ { min: 8 },
97
+ { min: 10, grow: 1 },
98
+ { min: 12 },
99
+ // 'haiku-4-5' is nine columns once the claude- prefix is stripped.
100
+ { min: 9 },
101
+ { min: 8 },
102
+ { min: 7 },
103
+ { min: 12, grow: 3 },
104
+ ], Math.max(40, width - 2));
105
+ }
106
+ const ALIGN = ['left', 'left', 'left', 'left', 'left', 'left', 'right', 'left'];
107
+ function requestHeader(width) {
108
+ const w = requestColumns(width);
109
+ return (0, render_1.dim)(' ' + (0, render_1.tableRow)(['', 'STATUS', 'ENTITY', 'AGENT', 'MODEL', 'VIA', 'DUR', 'PROMPT'], w, ALIGN));
110
+ }
111
+ function requestLine(r, now, selected, width, spin = 0) {
112
+ const w = requestColumns(width);
113
+ const colour = statusColour(r.status);
114
+ // A run that failed once and then succeeded on retry still carries the
115
+ // old error_code. Showing it on a DONE row reads as "this broke" when
116
+ // the work actually landed — so the code is only for rows that ended badly.
117
+ const endedBadly = r.status !== 'completed' && !exports.ACTIVE.has(r.status);
118
+ const tail = r.error_code && endedBadly
119
+ ? (0, render_1.red)(`[${r.error_code}]`)
120
+ : (r.prompt ?? '').replace(/\s+/g, ' ');
121
+ const cells = [
122
+ marker(r.status, spin),
123
+ colour(statusLabel(r.status)),
124
+ r.entity_name ?? '—',
125
+ (0, render_1.dim)(`${r.agent}/${r.mode}`),
126
+ (0, render_1.dim)((r.model ?? '—').replace(/^claude-/, '')),
127
+ (0, render_1.dim)(shortVia(r.issued_via)),
128
+ (0, render_1.dim)((0, render_1.fmtDuration)(durationOf(r, now))),
129
+ tail,
130
+ ];
131
+ const line = (0, render_1.tableRow)(cells, w, ALIGN);
132
+ return selected ? `${(0, render_1.cyan)('❯')} ${line}` : ` ${line}`;
133
+ }
@@ -1,16 +1,24 @@
1
+ import { type AppHost, type Screen } from './app';
1
2
  import type { DataLayer, RequestRow } from './data';
2
- import type { AppHost, Screen } from './app';
3
3
  export interface RequestsState {
4
4
  rows: RequestRow[];
5
5
  sel: number;
6
+ /** Index of the first visible row. */
7
+ top: number;
8
+ /** Rows the last render could fit — how far a PgDn moves, and what
9
+ * onKey needs to know to keep the cursor inside the window. */
10
+ rowsVisible: number;
6
11
  filter: string;
7
12
  filtering: boolean;
8
13
  loading: boolean;
9
14
  exhausted: boolean;
15
+ spin: number;
10
16
  now: number;
11
17
  }
12
18
  export declare function filterRows(rows: RequestRow[], filter: string): RequestRow[];
13
- export declare function renderRequests(st: RequestsState, width: number): string[];
19
+ /** Scroll the window the smallest distance that puts the cursor back in it. */
20
+ export declare function windowTop(sel: number, top: number, rows: number, total: number): number;
21
+ export declare function renderRequests(st: RequestsState, width: number, height: number): string[];
14
22
  export declare function createRequestsScreen(deps: {
15
23
  data: DataLayer;
16
24
  host: AppHost;
@@ -1,12 +1,20 @@
1
1
  "use strict";
2
- // Full request history for this node: cursor-paged, filterable.
2
+ // Full request history for this node: windowed, cursor-paged, filterable.
3
+ //
4
+ // The list is windowed rather than fully rendered. Rendering all loaded rows
5
+ // meant a 50-row page painted 50 lines into a 40-row terminal, and the heading
6
+ // and newest rows scrolled off the top of the alt screen.
3
7
  Object.defineProperty(exports, "__esModule", { value: true });
4
8
  exports.filterRows = filterRows;
9
+ exports.windowTop = windowTop;
5
10
  exports.renderRequests = renderRequests;
6
11
  exports.createRequestsScreen = createRequestsScreen;
7
12
  const render_1 = require("./render");
8
- const dashboard_1 = require("./dashboard");
13
+ const request_row_1 = require("./request-row");
14
+ const app_1 = require("./app");
9
15
  const PAGE = 50;
16
+ /** Lines the screen spends on things that are not rows. */
17
+ const CHROME = 6;
10
18
  function filterRows(rows, filter) {
11
19
  const f = filter.trim().toLowerCase();
12
20
  if (!f)
@@ -16,33 +24,59 @@ function filterRows(rows, filter) {
16
24
  (r.agent ?? '').toLowerCase().includes(f) ||
17
25
  (r.error_code ?? '').toLowerCase().includes(f));
18
26
  }
19
- function renderRequests(st, width) {
27
+ /** Scroll the window the smallest distance that puts the cursor back in it. */
28
+ function windowTop(sel, top, rows, total) {
29
+ const max = Math.max(0, total - rows);
30
+ let next = top;
31
+ if (sel < next)
32
+ next = sel;
33
+ if (sel >= next + rows)
34
+ next = sel - rows + 1;
35
+ return Math.min(Math.max(0, next), max);
36
+ }
37
+ function renderRequests(st, width, height) {
20
38
  const shown = filterRows(st.rows, st.filter);
39
+ const rows = Math.max(1, height - CHROME);
40
+ st.rowsVisible = rows;
41
+ st.top = windowTop(st.sel, st.top, rows, shown.length);
21
42
  const out = [];
22
- out.push(` ${(0, render_1.bold)('ALL REQUESTS')} ${(0, render_1.dim)(`${shown.length} shown`)}`);
43
+ const scope = st.filter ? `${shown.length} matching “${st.filter}”` : `${shown.length} loaded`;
44
+ out.push((0, app_1.heading)('ACTIVITY', scope));
23
45
  out.push('');
24
46
  if (shown.length === 0) {
25
47
  out.push((0, render_1.dim)(st.filter ? ' no requests match that filter' : ' no requests yet'));
26
48
  }
27
49
  else {
28
- shown.forEach((r, i) => out.push((0, dashboard_1.requestLine)(r, st.now, i === st.sel, width)));
50
+ out.push((0, request_row_1.requestHeader)(width));
51
+ shown
52
+ .slice(st.top, st.top + rows)
53
+ .forEach((r, i) => out.push((0, request_row_1.requestLine)(r, st.now, st.top + i === st.sel, width, st.spin)));
29
54
  }
30
- out.push('');
55
+ // Pad so the footer sits at the bottom of the screen rather than floating
56
+ // up under a short list.
57
+ while (out.length < height - 2)
58
+ out.push('');
31
59
  if (st.loading)
32
60
  out.push((0, render_1.dim)(' loading more…'));
33
61
  else if (st.exhausted)
34
- out.push((0, render_1.dim)(' end of history'));
62
+ out.push((0, render_1.dim)(` end of history — ${shown.length} rows`));
35
63
  else
36
- out.push((0, render_1.dim)(' ↓ at the bottom loads more'));
64
+ out.push((0, render_1.dim)(' ↓ past the last row loads more'));
37
65
  out.push(st.filtering
38
66
  ? ` ${(0, render_1.cyan)('/')}${st.filter}${(0, render_1.cyan)('▏')}`
39
- : (0, render_1.dim)(' ↑↓ move ⏎ transcript / filter q back'));
40
- return out.map(l => (0, render_1.truncate)(l, width));
67
+ : (0, app_1.footerHint)([
68
+ { keys: '↑↓', label: 'move' },
69
+ { keys: '⏎', label: 'transcript' },
70
+ { keys: '/', label: 'filter' },
71
+ { keys: 'q', label: 'back' },
72
+ ]));
73
+ return out.map(l => (0, render_1.truncate)(l, width)).slice(0, height);
41
74
  }
42
75
  function createRequestsScreen(deps) {
43
76
  const st = {
44
- rows: [], sel: 0, filter: '', filtering: false,
45
- loading: false, exhausted: false, now: Date.now(),
77
+ rows: [], sel: 0, top: 0, rowsVisible: 10,
78
+ filter: '', filtering: false,
79
+ loading: false, exhausted: false, spin: 0, now: Date.now(),
46
80
  };
47
81
  async function loadMore() {
48
82
  if (st.loading || st.exhausted)
@@ -65,12 +99,32 @@ function createRequestsScreen(deps) {
65
99
  deps.host.redraw();
66
100
  }
67
101
  }
102
+ const move = (delta, total) => {
103
+ st.sel = Math.min(Math.max(0, total - 1), Math.max(0, st.sel + delta));
104
+ st.top = windowTop(st.sel, st.top, st.rowsVisible, total);
105
+ };
68
106
  return {
69
107
  id: 'requests',
70
- title: 'All requests',
71
- render: (width) => renderRequests(st, width),
108
+ title: 'Activity',
109
+ render: (width, height) => renderRequests(st, width, height),
72
110
  pollMs: () => 5000,
73
111
  capturesKeys: () => st.filtering,
112
+ tick(n) {
113
+ st.spin = n;
114
+ // Durations only tick on rows that haven't finished.
115
+ const live = st.rows.slice(st.top, st.top + st.rowsVisible).some(r => request_row_1.ACTIVE.has(r.status));
116
+ if (live)
117
+ st.now = Date.now();
118
+ return live;
119
+ },
120
+ keys: () => [
121
+ { keys: '↑↓ / jk', label: 'move the cursor' },
122
+ { keys: 'PgUp/PgDn', label: 'move a screenful' },
123
+ { keys: 'g / G', label: 'first row / last loaded row' },
124
+ { keys: '⏎', label: 'open the transcript' },
125
+ { keys: '/', label: 'filter by entity, status, agent or error code' },
126
+ { keys: 'esc', label: 'clear the filter' },
127
+ ],
74
128
  async poll() {
75
129
  st.now = Date.now();
76
130
  if (st.rows.length === 0)
@@ -79,13 +133,23 @@ function createRequestsScreen(deps) {
79
133
  },
80
134
  async onKey(key) {
81
135
  if (st.filtering) {
82
- if (key.name === 'return' || key.name === 'escape') {
136
+ if (key.name === 'return') {
137
+ st.filtering = false;
138
+ deps.host.redraw();
139
+ return;
140
+ }
141
+ if (key.name === 'escape') {
83
142
  st.filtering = false;
143
+ st.filter = '';
144
+ st.sel = 0;
145
+ st.top = 0;
84
146
  deps.host.redraw();
85
147
  return;
86
148
  }
87
149
  if (key.name === 'backspace') {
88
150
  st.filter = st.filter.slice(0, -1);
151
+ st.sel = 0;
152
+ st.top = 0;
89
153
  deps.host.redraw();
90
154
  return;
91
155
  }
@@ -94,18 +158,38 @@ function createRequestsScreen(deps) {
94
158
  const ch = key.sequence ?? key.name ?? '';
95
159
  if (ch.length === 1 && ch >= ' ') {
96
160
  st.filter += ch;
161
+ st.sel = 0;
162
+ st.top = 0;
97
163
  deps.host.redraw();
98
164
  }
99
165
  return;
100
166
  }
101
167
  const shown = filterRows(st.rows, st.filter);
168
+ const page = Math.max(1, st.rowsVisible - 1);
102
169
  if (key.name === 'up' || key.name === 'k') {
103
- st.sel = Math.max(0, st.sel - 1);
170
+ move(-1, shown.length);
171
+ deps.host.redraw();
172
+ return;
173
+ }
174
+ if (key.name === 'pageup') {
175
+ move(-page, shown.length);
104
176
  deps.host.redraw();
105
177
  return;
106
178
  }
107
- if (key.name === 'down' || key.name === 'j') {
108
- st.sel = Math.min(Math.max(0, shown.length - 1), st.sel + 1);
179
+ if (key.name === 'g' && !key.shift) {
180
+ st.sel = 0;
181
+ st.top = 0;
182
+ deps.host.redraw();
183
+ return;
184
+ }
185
+ if (key.name === 'g' && key.shift) {
186
+ move(shown.length, shown.length);
187
+ deps.host.redraw();
188
+ return;
189
+ }
190
+ if (key.name === 'down' || key.name === 'j' || key.name === 'pagedown') {
191
+ move(key.name === 'pagedown' ? page : 1, shown.length);
192
+ // Reaching the end of what's loaded is the request for more.
109
193
  if (st.sel >= shown.length - 1)
110
194
  await loadMore();
111
195
  deps.host.redraw();
@@ -115,6 +199,14 @@ function createRequestsScreen(deps) {
115
199
  st.filtering = true;
116
200
  st.filter = '';
117
201
  st.sel = 0;
202
+ st.top = 0;
203
+ deps.host.redraw();
204
+ return;
205
+ }
206
+ if (key.name === 'escape' && st.filter) {
207
+ st.filter = '';
208
+ st.sel = 0;
209
+ st.top = 0;
118
210
  deps.host.redraw();
119
211
  return;
120
212
  }
package/dist/tui/run.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { FEED_ROWS } from './dashboard';
2
1
  export declare function shouldRenderTui(env: {
3
2
  isTTY: boolean;
4
3
  stdinTTY: boolean;
@@ -10,4 +9,5 @@ export declare function runDashboard(opts: {
10
9
  startedAt: number | null;
11
10
  version: string;
12
11
  }): Promise<void>;
13
- export { FEED_ROWS };
12
+ /** `ainode harnesses` — straight to the harness screen. */
13
+ export declare function runHarnessesTui(): Promise<void>;