@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.
- package/dist/api/client.d.ts +2 -1
- package/dist/api/client.js +3 -0
- package/dist/api/dto/inbox.d.ts +1 -1
- package/dist/api/dto/nodes.d.ts +16 -0
- package/dist/clients/attach/ansi-cells.d.ts +13 -4
- package/dist/clients/attach/ansi-cells.js +16 -10
- package/dist/clients/attach/overlays/help.js +6 -0
- package/dist/clients/attach/render/transcript-search.d.ts +28 -0
- package/dist/clients/attach/render/transcript-search.js +153 -0
- package/dist/clients/attach/render/viewport.d.ts +10 -1
- package/dist/clients/attach/render/viewport.js +27 -2
- package/dist/clients/attach/session/frame.d.ts +2 -1
- package/dist/clients/attach/session/frame.js +9 -0
- package/dist/clients/attach/session/keys.d.ts +5 -0
- package/dist/clients/attach/session/keys.js +3 -0
- package/dist/clients/attach/session/layout.d.ts +2 -2
- package/dist/clients/attach/session/layout.js +3 -2
- package/dist/clients/attach/session/transcript-search.d.ts +20 -0
- package/dist/clients/attach/session/transcript-search.js +151 -0
- package/dist/clients/attach/viewer.js +742 -740
- package/dist/core/__tests__/seam/dormancy-release.test.js +2 -0
- package/dist/core/human/scan.d.ts +1 -3
- package/dist/core/human/scan.js +3 -3
- package/dist/core/human/types.d.ts +1 -0
- package/dist/core/keybindings/attach-control.d.ts +3 -0
- package/dist/core/keybindings/attach-control.js +1 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +6 -0
- package/dist/core/runtime/node-read.d.ts +11 -0
- package/dist/core/runtime/node-read.js +107 -41
- package/dist/daemon/api/handlers/inbox.js +1 -0
- package/dist/daemon/api/handlers/nodes.js +34 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
package/dist/api/client.d.ts
CHANGED
|
@@ -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>;
|
package/dist/api/client.js
CHANGED
|
@@ -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) {
|
package/dist/api/dto/inbox.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ export interface PageTicketSummaryDTO {
|
|
|
30
30
|
source: TicketSourceDTO;
|
|
31
31
|
slot_kinds: string[];
|
|
32
32
|
awaits_response: boolean;
|
|
33
|
-
state
|
|
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
|
}
|
package/dist/api/dto/nodes.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
*
|
|
83
|
-
* block, never past
|
|
84
|
-
|
|
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 +=
|
|
93
|
+
out += style.on;
|
|
92
94
|
inside = true;
|
|
93
95
|
} };
|
|
94
96
|
const close = () => { if (inside) {
|
|
95
|
-
out +=
|
|
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 +=
|
|
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
|
|
117
|
-
out += ' '.repeat(gap) +
|
|
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
|
-
|
|
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
|
|
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;
|