@north-light/crouter 0.3.200 → 0.3.201

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/api/client.d.ts +2 -1
  2. package/dist/api/client.js +3 -0
  3. package/dist/api/dto/inbox.d.ts +1 -1
  4. package/dist/api/dto/nodes.d.ts +16 -0
  5. package/dist/clients/attach/ansi-cells.d.ts +13 -4
  6. package/dist/clients/attach/ansi-cells.js +16 -10
  7. package/dist/clients/attach/overlays/help.js +6 -0
  8. package/dist/clients/attach/render/transcript-search.d.ts +28 -0
  9. package/dist/clients/attach/render/transcript-search.js +153 -0
  10. package/dist/clients/attach/render/viewport.d.ts +10 -1
  11. package/dist/clients/attach/render/viewport.js +27 -2
  12. package/dist/clients/attach/session/frame.d.ts +2 -1
  13. package/dist/clients/attach/session/frame.js +9 -0
  14. package/dist/clients/attach/session/keys.d.ts +5 -0
  15. package/dist/clients/attach/session/keys.js +3 -0
  16. package/dist/clients/attach/session/layout.d.ts +2 -2
  17. package/dist/clients/attach/session/layout.js +3 -2
  18. package/dist/clients/attach/session/transcript-search.d.ts +20 -0
  19. package/dist/clients/attach/session/transcript-search.js +151 -0
  20. package/dist/clients/attach/viewer.js +742 -740
  21. package/dist/core/__tests__/seam/dormancy-release.test.js +2 -0
  22. package/dist/core/human/scan.d.ts +1 -3
  23. package/dist/core/human/scan.js +3 -3
  24. package/dist/core/human/types.d.ts +1 -0
  25. package/dist/core/keybindings/attach-control.d.ts +3 -0
  26. package/dist/core/keybindings/attach-control.js +1 -0
  27. package/dist/core/keybindings/catalog.d.ts +2 -2
  28. package/dist/core/keybindings/catalog.js +6 -0
  29. package/dist/core/runtime/node-read.d.ts +11 -0
  30. package/dist/core/runtime/node-read.js +107 -41
  31. package/dist/daemon/api/handlers/inbox.js +1 -0
  32. package/dist/daemon/api/handlers/nodes.js +34 -1
  33. package/package.json +1 -1
  34. package/runtime.lock.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import type { DaemonRestartDTO, HealthDTO, StatusDTO } from './dto/health.js';
2
2
  import type { BashJobStatusDTO, BashJobStopResultDTO } from './dto/bash-jobs.js';
3
- import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeSessionDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
3
+ import type { ArtifactListDTO, ArtifactsQuery, ContextListDTO, CreateNodeRequest, ListNodesQuery, NodeDetailDTO, NodeMessagesPageDTO, NodeMessagesQuery, NodeSessionDTO, NodeSnapshotDTO, NodeSummaryDTO, TranscriptDTO, TranscriptQuery } from './dto/nodes.js';
4
4
  import type { InterruptResultDTO, MessageResultDTO, SendMessageRequest } from './dto/messages.js';
5
5
  import type { PushReportRequest, PushReportResultDTO, ReportDTO, ReportsQuery } from './dto/reports.js';
6
6
  import type { CloseRequest, CloseResultDTO, PromoteRequest, RelaunchRootResultDTO, ReviveRequest, ReviveResultDTO, WaitRequest, YieldRequest } from './dto/lifecycle.js';
@@ -143,6 +143,7 @@ export declare class CrtrClient {
143
143
  getReports(id: string, q?: ReportsQuery): Promise<ReportDTO[]>;
144
144
  getTranscript(id: string, q?: TranscriptQuery): Promise<TranscriptDTO>;
145
145
  getSnapshot(id: string): Promise<NodeSnapshotDTO>;
146
+ getNodeMessages(id: string, q?: NodeMessagesQuery): Promise<NodeMessagesPageDTO>;
146
147
  /** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
147
148
  * assembled system prompt. For exports; `getSnapshot` is for renderers. */
148
149
  getSession(id: string): Promise<NodeSessionDTO>;
@@ -253,6 +253,9 @@ export class CrtrClient {
253
253
  getSnapshot(id) {
254
254
  return this.request('GET', routes.nodeSnapshot(this.nodePath(id)));
255
255
  }
256
+ getNodeMessages(id, q) {
257
+ return this.request('GET', withQuery(routes.nodeMessages(this.nodePath(id)), q));
258
+ }
256
259
  /** The node's conversation exactly as it ran — raw `.jsonl` bytes plus the
257
260
  * assembled system prompt. For exports; `getSnapshot` is for renderers. */
258
261
  getSession(id) {
@@ -30,7 +30,7 @@ export interface PageTicketSummaryDTO {
30
30
  source: TicketSourceDTO;
31
31
  slot_kinds: string[];
32
32
  awaits_response: boolean;
33
- state?: 'pending' | 'resolved' | 'canceled' | 'passive';
33
+ state: 'pending' | 'resolved' | 'canceled' | 'passive';
34
34
  /** Present only on resolved history entries; the stored one-line answer digest. */
35
35
  answer_digest?: string;
36
36
  }
@@ -175,6 +175,22 @@ export interface NodeSnapshotDTO {
175
175
  }[];
176
176
  captured_at: IsoTime;
177
177
  }
178
+ /** `GET /v1/nodes/{id}/messages` query. `cursor` walks backward from the
179
+ * newest messages; each returned page remains chronological. */
180
+ export interface NodeMessagesQuery {
181
+ cursor?: Cursor;
182
+ limit?: number;
183
+ }
184
+ /** `GET /v1/nodes/{id}/messages` — a backward-paged window over the node's
185
+ * cycle-flattened visible history. */
186
+ export interface NodeMessagesPageDTO {
187
+ node_id: NodeIdDTO;
188
+ /** pi AgentMessage[] JSON, chronological ascending within this page. */
189
+ messages: unknown[];
190
+ /** Opaque cursor toward older messages; null at the start of the session. */
191
+ next_cursor: Cursor | null;
192
+ captured_at: IsoTime;
193
+ }
178
194
  /** `GET /v1/nodes/{id}/session` — the node's conversation exactly as it ran:
179
195
  * the raw session `.jsonl` bytes plus the assembled system prompt. Unlike
180
196
  * `NodeSnapshotDTO` nothing is reconstructed or flattened, so every
@@ -24,8 +24,17 @@ export declare function rowCells(line: string): number;
24
24
  /** Plain text of cells `[from, to)` — every escape, link boundary, and cursor
25
25
  * marker dropped, exactly what a terminal copies out of that span. */
26
26
  export declare function sliceCells(line: string, from: number, to: number): string;
27
- /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
28
- * the span (a reset there would otherwise drop the highlight mid-selection).
29
- * A short row is padded with spaces so a multi-row selection reads as one
30
- * block, never past `maxCells`. */
27
+ /** The SGR pair `styleCells` asserts over a span. */
28
+ export type CellStyle = {
29
+ on: string;
30
+ off: string;
31
+ };
32
+ export declare const INVERSE_STYLE: CellStyle;
33
+ /** Style cells `[from, to)`, re-asserting `style.on` after any SGR inside the
34
+ * span (a reset there would otherwise drop the styling mid-span). A short row
35
+ * is padded with spaces so a multi-row span reads as one block, never past
36
+ * `maxCells`. Escapes are non-printing, so passes compose: styling an already
37
+ * styled row leaves every cell position unmoved. */
38
+ export declare function styleCells(line: string, from: number, to: number, maxCells: number, style?: CellStyle): string;
39
+ /** Reverse-video cells `[from, to)` — the drag-selection highlight. */
31
40
  export declare function highlightCells(line: string, from: number, to: number, maxCells: number): string;
@@ -77,22 +77,24 @@ export function sliceCells(line, from, to) {
77
77
  });
78
78
  return text;
79
79
  }
80
- /** Reverse-video cells `[from, to)`, re-asserting inverse after any SGR inside
81
- * the span (a reset there would otherwise drop the highlight mid-selection).
82
- * A short row is padded with spaces so a multi-row selection reads as one
83
- * block, never past `maxCells`. */
84
- export function highlightCells(line, from, to, maxCells) {
80
+ export const INVERSE_STYLE = { on: INVERSE_ON, off: INVERSE_OFF };
81
+ /** Style cells `[from, to)`, re-asserting `style.on` after any SGR inside the
82
+ * span (a reset there would otherwise drop the styling mid-span). A short row
83
+ * is padded with spaces so a multi-row span reads as one block, never past
84
+ * `maxCells`. Escapes are non-printing, so passes compose: styling an already
85
+ * styled row leaves every cell position unmoved. */
86
+ export function styleCells(line, from, to, maxCells, style = INVERSE_STYLE) {
85
87
  const end = Math.min(to, maxCells);
86
88
  if (end <= from || from < 0)
87
89
  return line;
88
90
  let out = '';
89
91
  let inside = false;
90
92
  const open = () => { if (!inside) {
91
- out += INVERSE_ON;
93
+ out += style.on;
92
94
  inside = true;
93
95
  } };
94
96
  const close = () => { if (inside) {
95
- out += INVERSE_OFF;
97
+ out += style.off;
96
98
  inside = false;
97
99
  } };
98
100
  const cells = walkRow(line, {
@@ -100,7 +102,7 @@ export function highlightCells(line, from, to, maxCells) {
100
102
  out += escape;
101
103
  // An SGR inside the span may have reset every attribute, including ours.
102
104
  if (inside && escape.endsWith('m'))
103
- out += INVERSE_ON;
105
+ out += style.on;
104
106
  },
105
107
  onGlyph: (glyph) => {
106
108
  if (glyph.cell >= from && glyph.cell < end)
@@ -113,8 +115,12 @@ export function highlightCells(line, from, to, maxCells) {
113
115
  close();
114
116
  if (cells < end) {
115
117
  const gap = Math.max(0, from - cells);
116
- const highlighted = end - Math.max(from, cells);
117
- out += ' '.repeat(gap) + INVERSE_ON + ' '.repeat(highlighted) + INVERSE_OFF;
118
+ const styled = end - Math.max(from, cells);
119
+ out += ' '.repeat(gap) + style.on + ' '.repeat(styled) + style.off;
118
120
  }
119
121
  return out;
120
122
  }
123
+ /** Reverse-video cells `[from, to)` — the drag-selection highlight. */
124
+ export function highlightCells(line, from, to, maxCells) {
125
+ return styleCells(line, from, to, maxCells);
126
+ }
@@ -134,6 +134,12 @@ export class HelpOverlay {
134
134
  binding('crtr.attach.scroll.live', 'return to the live bottom'),
135
135
  muted('mouse wheel (when reporting is on)'),
136
136
  blank,
137
+ heading('Searching the transcript'),
138
+ { text: `${this.binding('crtr.attach.search')} search what the transcript is currently showing. Type to jump as you go, enter to browse the matches.`, style: 'plain' },
139
+ { text: `${this.binding('crtr.attach.search.next')} / ${this.binding('crtr.attach.search.previous')} next and previous match · ${this.binding('crtr.attach.search.edit')} edit the query · ${this.binding('crtr.attach.search.close')} close. Typing anything else closes search and returns to the editor.`, style: 'plain' },
140
+ { text: 'A lowercase query ignores case; any uppercase letter makes it case-sensitive.', style: 'plain' },
141
+ { text: 'Folded tool output is not on screen, so it is not searched — unfold it to include it.', style: 'muted' },
142
+ blank,
137
143
  heading('Selection and copy'),
138
144
  { text: 'Drag with the mouse to select; releasing copies the selection to the clipboard (and the tmux buffer). Any key clears the highlight.', style: 'plain' },
139
145
  { text: 'prefix + [ tmux copy-mode selects and copies the visible screenful; it works even while mouse reporting is on.', style: 'plain' },
@@ -0,0 +1,28 @@
1
+ import type { ViewportSource } from './measured-container.js';
2
+ /** One hit: an absolute content line and the cells it covers on that line. */
3
+ export interface SearchMatch {
4
+ line: number;
5
+ from: number;
6
+ to: number;
7
+ }
8
+ export interface TranscriptSearchModel {
9
+ /** Replace the query; matches and the current hit are recomputed lazily. */
10
+ setQuery(query: string, near: number | undefined): void;
11
+ query(): string;
12
+ /** Every hit, oldest to newest, against the current content. */
13
+ matches(width: number): readonly SearchMatch[];
14
+ /** The selected hit, or undefined when the query finds nothing. */
15
+ current(width: number): SearchMatch | undefined;
16
+ /** Number of hits and the 1-indexed position of the selected one. */
17
+ status(width: number): {
18
+ index: number;
19
+ total: number;
20
+ };
21
+ /** Step the selection; wraps at both ends. Returns the new selection. */
22
+ step(width: number, direction: 1 | -1): SearchMatch | undefined;
23
+ /** Re-select the first hit at or after `line`, else the last one. */
24
+ selectNear(width: number, line: number): SearchMatch | undefined;
25
+ /** Paint hits onto one rendered transcript row. Identity without a query. */
26
+ decorate(line: number, rendered: string, width: number): string;
27
+ }
28
+ export declare function createTranscriptSearchModel(source: ViewportSource): TranscriptSearchModel;
@@ -0,0 +1,153 @@
1
+ // render/transcript-search.ts — the transcript search match model.
2
+ //
3
+ // Search runs over the RENDERED lines of the viewport source, not over a
4
+ // separate text record, so it finds exactly what is on screen: a collapsed tool
5
+ // group contributes no blocks to `ViewportSource`, so its output is not
6
+ // searchable while it is folded, and folding it back open makes it searchable
7
+ // again with no bookkeeping here.
8
+ //
9
+ // Matches are held as absolute content lines and terminal cell ranges, which is
10
+ // what the viewport needs to scroll to one and what `highlightCells` needs to
11
+ // paint one.
12
+ import { styleCells, walkRow } from '../ansi-cells.js';
13
+ /** Every hit is reverse-video; the one you are standing on is also underlined,
14
+ * so a screen with several hits still says which of them `n` will leave.
15
+ * Closing a span restores nothing, so the extra attribute is underline rather
16
+ * than bold: transcript text runs bold often and underlined almost never. */
17
+ const HIT_STYLE = { on: '\x1b[7m', off: '\x1b[27m' };
18
+ const SELECTED_HIT_STYLE = { on: '\x1b[7m\x1b[4m', off: '\x1b[24m\x1b[27m' };
19
+ function plainRow(line) {
20
+ let text = '';
21
+ const cells = [];
22
+ const width = walkRow(line, {
23
+ onGlyph: (glyph) => {
24
+ // One entry per code unit, so a string index maps straight back to a cell
25
+ // even across wide graphemes and surrogate pairs.
26
+ for (let i = 0; i < glyph.text.length; i++)
27
+ cells.push(glyph.cell);
28
+ text += glyph.text;
29
+ },
30
+ });
31
+ return { text, cells, width };
32
+ }
33
+ /** Vim smartcase: an all-lowercase query ignores case, any uppercase pins it. */
34
+ function caseSensitive(query) {
35
+ return query !== query.toLowerCase();
36
+ }
37
+ function rowMatches(row, query, sensitive, line, out) {
38
+ const haystack = sensitive ? row.text : row.text.toLowerCase();
39
+ const needle = sensitive ? query : query.toLowerCase();
40
+ let at = haystack.indexOf(needle);
41
+ while (at !== -1) {
42
+ const end = at + needle.length;
43
+ const from = row.cells[at] ?? 0;
44
+ const to = end < row.cells.length ? row.cells[end] : row.width;
45
+ if (to > from)
46
+ out.push({ line, from, to });
47
+ at = haystack.indexOf(needle, at + Math.max(1, needle.length));
48
+ }
49
+ }
50
+ export function createTranscriptSearchModel(source) {
51
+ let query = '';
52
+ let selected = 0;
53
+ let cached = [];
54
+ let cachedByLine = new Map();
55
+ let cacheKey;
56
+ /** Where to re-anchor the selection after the next recompute. */
57
+ let pendingNear;
58
+ function ensure(width) {
59
+ const key = `${width}:${source.revision()}:${query}`;
60
+ if (cacheKey === key)
61
+ return cached;
62
+ const found = [];
63
+ if (query !== '') {
64
+ const sensitive = caseSensitive(query);
65
+ const count = source.blockCount();
66
+ for (let block = 0; block < count; block++) {
67
+ const start = source.blockStart(block, width);
68
+ const lines = source.blockLines(block, width);
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const line = lines[i];
71
+ // Cheap reject before the cell walk: most rows never contain the
72
+ // query, and plainRow() is the expensive part of this scan.
73
+ if (!line.toLowerCase().includes(query.toLowerCase()))
74
+ continue;
75
+ rowMatches(plainRow(line), query, sensitive, start + i, found);
76
+ }
77
+ }
78
+ }
79
+ const byLine = new Map();
80
+ for (const match of found) {
81
+ const bucket = byLine.get(match.line);
82
+ if (bucket)
83
+ bucket.push(match);
84
+ else
85
+ byLine.set(match.line, [match]);
86
+ }
87
+ cached = found;
88
+ cachedByLine = byLine;
89
+ cacheKey = key;
90
+ if (pendingNear !== undefined) {
91
+ const near = pendingNear;
92
+ pendingNear = undefined;
93
+ const at = found.findIndex((match) => match.line >= near);
94
+ selected = at === -1 ? Math.max(0, found.length - 1) : at;
95
+ }
96
+ else if (selected >= found.length) {
97
+ selected = Math.max(0, found.length - 1);
98
+ }
99
+ return cached;
100
+ }
101
+ return {
102
+ setQuery(next, near) {
103
+ if (next === query)
104
+ return;
105
+ query = next;
106
+ pendingNear = near;
107
+ cacheKey = undefined;
108
+ },
109
+ query() {
110
+ return query;
111
+ },
112
+ matches(width) {
113
+ return ensure(width);
114
+ },
115
+ current(width) {
116
+ const found = ensure(width);
117
+ return found[selected];
118
+ },
119
+ status(width) {
120
+ const found = ensure(width);
121
+ return { index: found.length === 0 ? 0 : selected + 1, total: found.length };
122
+ },
123
+ step(width, direction) {
124
+ const found = ensure(width);
125
+ if (found.length === 0)
126
+ return undefined;
127
+ selected = (selected + direction + found.length) % found.length;
128
+ return found[selected];
129
+ },
130
+ selectNear(width, line) {
131
+ const found = ensure(width);
132
+ if (found.length === 0)
133
+ return undefined;
134
+ const at = found.findIndex((match) => match.line >= line);
135
+ selected = at === -1 ? found.length - 1 : at;
136
+ return found[selected];
137
+ },
138
+ decorate(line, rendered, width) {
139
+ if (query === '')
140
+ return rendered;
141
+ const found = ensure(width);
142
+ const bucket = cachedByLine.get(line);
143
+ if (bucket === undefined)
144
+ return rendered;
145
+ const selectedMatch = found[selected];
146
+ let painted = rendered;
147
+ for (const match of bucket) {
148
+ painted = styleCells(painted, match.from, match.to, width, match === selectedMatch ? SELECTED_HIT_STYLE : HIT_STYLE);
149
+ }
150
+ return painted;
151
+ },
152
+ };
153
+ }
@@ -18,6 +18,12 @@ export interface TranscriptViewport {
18
18
  pageRows(): number;
19
19
  /** Pin back to the live bottom. */
20
20
  goLive(): void;
21
+ /** Bring absolute content `line` into view, seated a third of the way down
22
+ * so the context around it reads. */
23
+ scrollToLine(line: number): void;
24
+ /** Absolute content line at the viewport's top row, or undefined before the
25
+ * first render. */
26
+ topLine(): number | undefined;
21
27
  /** True while the viewport is pinned to the bottom. */
22
28
  isLive(): boolean;
23
29
  /** Flat blocks intersecting the visible slice and its last whole block. */
@@ -25,5 +31,8 @@ export interface TranscriptViewport {
25
31
  /** Forward frame invalidation without exposing the source to the frame. */
26
32
  invalidate(): void;
27
33
  }
28
- export declare function createTranscriptViewport(source: ViewportSource): TranscriptViewport;
34
+ /** Restyle one rendered transcript row, addressed by its absolute content
35
+ * line — how transcript search paints its hits. */
36
+ export type RowDecorator = (line: number, rendered: string, width: number) => string;
37
+ export declare function createTranscriptViewport(source: ViewportSource, decorate?: RowDecorator): TranscriptViewport;
29
38
  export {};
@@ -18,7 +18,7 @@ function firstBlockAfter(source, line, width) {
18
18
  }
19
19
  return low;
20
20
  }
21
- export function createTranscriptViewport(source) {
21
+ export function createTranscriptViewport(source, decorate) {
22
22
  const state = { follow: true, anchor: undefined };
23
23
  let lastWidth;
24
24
  let lastRows = 0;
@@ -101,9 +101,13 @@ export function createTranscriptViewport(source) {
101
101
  if (partiallyClipped && isInlineImageBlock(lines)) {
102
102
  content.push(...Array(visibleEnd - visibleStart).fill(CLIPPED_IMAGE_ROW));
103
103
  }
104
- else {
104
+ else if (decorate === undefined) {
105
105
  content.push(...lines.slice(visibleStart, visibleEnd));
106
106
  }
107
+ else {
108
+ for (let row = visibleStart; row < visibleEnd; row++)
109
+ content.push(decorate(start + row, lines[row], width));
110
+ }
107
111
  }
108
112
  const padded = content.length >= viewportRows
109
113
  ? content.slice(0, viewportRows)
@@ -140,6 +144,27 @@ export function createTranscriptViewport(source) {
140
144
  state.follow = true;
141
145
  state.anchor = undefined;
142
146
  },
147
+ scrollToLine(line) {
148
+ if (lastWidth === undefined || lastRows <= 0)
149
+ return;
150
+ const total = source.totalHeight(lastWidth);
151
+ const bottom = Math.max(0, total - lastRows);
152
+ const top = Math.min(Math.max(0, line - Math.floor(lastRows / 3)), bottom);
153
+ if (top >= bottom) {
154
+ state.follow = true;
155
+ state.anchor = undefined;
156
+ return;
157
+ }
158
+ state.follow = false;
159
+ state.anchor = source.anchorAt(top, lastWidth);
160
+ if (!state.anchor)
161
+ state.follow = true;
162
+ },
163
+ topLine() {
164
+ if (lastWidth === undefined || lastRows <= 0)
165
+ return undefined;
166
+ return resolveTop(lastWidth, lastRows, source.totalHeight(lastWidth));
167
+ },
143
168
  isLive() {
144
169
  return state.follow;
145
170
  },
@@ -4,7 +4,8 @@ export declare const MIN_TRANSCRIPT_ROWS = 3;
4
4
  export declare const HEADER_MAX_ROWS = 12;
5
5
  export declare const EDITOR_MIN_ROWS = 1;
6
6
  export declare const STATUS_ROWS = 1;
7
- export type BottomSlot = 'queued' | 'aboveWidgets' | 'activity' | 'cockpitPadding' | 'editor' | 'pickerPanel' | 'belowWidgets' | 'reports' | 'footer';
7
+ export declare const SEARCH_ROWS = 1;
8
+ export type BottomSlot = 'queued' | 'aboveWidgets' | 'activity' | 'cockpitPadding' | 'editor' | 'pickerPanel' | 'belowWidgets' | 'reports' | 'search' | 'footer';
8
9
  /** Crop `lines` to `alloc` rows, guaranteeing the CURSOR_MARKER row survives
9
10
  * (§2.1). No marker → the tail is kept. */
10
11
  export declare function cropAroundCursor(lines: readonly string[], alloc: number): string[];
@@ -10,6 +10,7 @@ export const MIN_TRANSCRIPT_ROWS = 3;
10
10
  export const HEADER_MAX_ROWS = 12;
11
11
  export const EDITOR_MIN_ROWS = 1;
12
12
  export const STATUS_ROWS = 1;
13
+ export const SEARCH_ROWS = 1;
13
14
  /** Crop `lines` to `alloc` rows, guaranteeing the CURSOR_MARKER row survives
14
15
  * (§2.1). No marker → the tail is kept. */
15
16
  export function cropAroundCursor(lines, alloc) {
@@ -48,6 +49,7 @@ const SLOT_VISUAL_ORDER = [
48
49
  'pickerPanel',
49
50
  'belowWidgets',
50
51
  'reports',
52
+ 'search',
51
53
  'footer',
52
54
  ];
53
55
  /** The TUI's ONE child. Renders exactly `terminal.rows` lines, every frame. */
@@ -76,6 +78,13 @@ export class ViewerFrame {
76
78
  const statusAlloc = footerLines.length > 0 && remaining > EDITOR_MIN_ROWS ? STATUS_ROWS : 0;
77
79
  allocate('footer', statusAlloc === 0 ? [] : footerLines.slice(-statusAlloc));
78
80
  remaining -= statusAlloc;
81
+ // Search sits with the status line rather than in the priority table: while
82
+ // it is up it owns typed input, so a frame that drops it would leave the
83
+ // user typing into an invisible mode.
84
+ const searchLines = linesFor('search');
85
+ const searchAlloc = searchLines.length > 0 && remaining > EDITOR_MIN_ROWS ? SEARCH_ROWS : 0;
86
+ allocate('search', searchAlloc === 0 ? [] : searchLines.slice(-searchAlloc));
87
+ remaining -= searchAlloc;
79
88
  const pickerLines = linesFor('pickerPanel');
80
89
  const pickerReserve = pickerLines.length === 0 ? 0 : Math.min(pickerLines.length, Math.floor(remaining / 2));
81
90
  const editorLines = linesFor('editor');
@@ -11,6 +11,9 @@ export interface KeyHooks {
11
11
  hasExclusiveSurface: () => boolean;
12
12
  /** Scroll actions for this session's transcript viewport. */
13
13
  viewportActions: () => AttachViewportActions;
14
+ /** Transcript search's chance at a chunk; true when it consumed it. It owns
15
+ * typed characters while editing a query, so it runs ahead of the editor. */
16
+ searchHandleKey: (data: string) => boolean;
14
17
  /** The roster's chance at a chunk; true when it consumed it. */
15
18
  rosterHandleKey: (data: string) => boolean;
16
19
  setNotice: (message: string) => void;
@@ -25,6 +28,8 @@ export interface KeyHooks {
25
28
  openFileReview: () => void;
26
29
  /** Search the current node's local profile project files. */
27
30
  openProfileFiles: () => void;
31
+ /** Open incremental search over the displayed transcript. */
32
+ openSearch: () => void;
28
33
  /** Play the whip animation and interrupt the focused agent with a command. */
29
34
  whip: () => void;
30
35
  }
@@ -39,6 +39,7 @@ export function installGlobalKeys(ctx, hooks) {
39
39
  'crtr.attach.command.inspect': hooks.inspectLoadedCommand,
40
40
  'crtr.attach.file-review': hooks.openFileReview,
41
41
  'crtr.attach.profile-files': hooks.openProfileFiles,
42
+ 'crtr.attach.search': hooks.openSearch,
42
43
  'crtr.attach.whip': hooks.whip,
43
44
  };
44
45
  return tui.addInputListener((data) => {
@@ -56,6 +57,8 @@ export function installGlobalKeys(ctx, hooks) {
56
57
  }
57
58
  if (hooks.hasExclusiveSurface())
58
59
  return undefined;
60
+ if (hooks.searchHandleKey(data))
61
+ return { consume: true };
59
62
  // Let focused pickers and overlays retain their native page navigation.
60
63
  if (editor.focused && dispatchAttachViewportBinding(hooks.bindings(), data, hooks.viewportActions()))
61
64
  return { consume: true };
@@ -1,4 +1,4 @@
1
- import { Container, type KeybindingsManager, type ProcessTerminal, type TUI } from '@earendil-works/pi-tui';
1
+ import { Container, type Component, type KeybindingsManager, type ProcessTerminal, type TUI } from '@earendil-works/pi-tui';
2
2
  import type { AttachPalette } from '../config.js';
3
3
  import { TitledEditor } from '../input/titled-editor.js';
4
4
  import type { TranscriptViewport } from '../render/viewport.js';
@@ -19,4 +19,4 @@ export interface AttachLayout {
19
19
  }
20
20
  /** Build the layout around the transcript viewport. The viewer creates the
21
21
  * terminal and chat first because the viewport measures that chat source. */
22
- export declare function createLayout(procTerminal: ProcessTerminal, tui: TUI, chat: Container, activity: Container, pal: AttachPalette, km: KeybindingsManager, viewport: TranscriptViewport): AttachLayout;
22
+ export declare function createLayout(procTerminal: ProcessTerminal, tui: TUI, chat: Container, activity: Container, pal: AttachPalette, km: KeybindingsManager, viewport: TranscriptViewport, searchBar: Component): AttachLayout;
@@ -8,7 +8,7 @@
8
8
  // The render order is asserted in ONE place (`ViewerFrame`'s bottom list).
9
9
  // Top to bottom: pinned recap header · virtualized chat · cockpit padding · fixed activity ·
10
10
  // above-editor extension widgets · queued · editor · picker panel · below-editor
11
- // extension widgets · roster · status bar. The frame owns the pane rectangle,
11
+ // extension widgets · roster · search bar · status bar. The frame owns the pane rectangle,
12
12
  // so every region remains on screen and none can enter terminal scrollback.
13
13
  import { Container, Spacer } from '@earendil-works/pi-tui';
14
14
  import { getSelectListTheme } from '@earendil-works/pi-coding-agent';
@@ -16,7 +16,7 @@ import { TitledEditor } from '../input/titled-editor.js';
16
16
  import { ViewerFrame } from './frame.js';
17
17
  /** Build the layout around the transcript viewport. The viewer creates the
18
18
  * terminal and chat first because the viewport measures that chat source. */
19
- export function createLayout(procTerminal, tui, chat, activity, pal, km, viewport) {
19
+ export function createLayout(procTerminal, tui, chat, activity, pal, km, viewport, searchBar) {
20
20
  const containers = {
21
21
  chat,
22
22
  header: new Container(),
@@ -44,6 +44,7 @@ export function createLayout(procTerminal, tui, chat, activity, pal, km, viewpor
44
44
  { slot: 'pickerPanel', component: containers.pickerPanel },
45
45
  { slot: 'belowWidgets', component: containers.belowWidgets },
46
46
  { slot: 'reports', component: containers.reports },
47
+ { slot: 'search', component: searchBar },
47
48
  { slot: 'footer', component: containers.footer },
48
49
  ],
49
50
  });
@@ -0,0 +1,20 @@
1
+ import { type Component, type TUI } from '@earendil-works/pi-tui';
2
+ import type { AttachPalette } from '../config.js';
3
+ import type { TranscriptViewport } from '../render/viewport.js';
4
+ import type { TranscriptSearchModel } from '../render/transcript-search.js';
5
+ import { type AttachBindings } from './bindings.js';
6
+ export interface TranscriptSearch {
7
+ /** The bar, mounted as a fixed frame slot; renders nothing while closed. */
8
+ readonly component: Component;
9
+ /** Enter search. A repeat open re-edits the previous query. */
10
+ open(): void;
11
+ close(): void;
12
+ /** True when search owns the key chunk. Declines everything it does not own,
13
+ * so scroll bindings and ordinary editor input still work while it is up. */
14
+ handleKey(data: string): boolean;
15
+ }
16
+ export interface TranscriptSearchHooks {
17
+ bindings: () => AttachBindings;
18
+ setNotice: (message: string) => void;
19
+ }
20
+ export declare function createTranscriptSearch(tui: TUI, pal: AttachPalette, viewport: TranscriptViewport, model: TranscriptSearchModel, hooks: TranscriptSearchHooks): TranscriptSearch;