@cueloop/client 0.1.0-alpha.74 → 0.1.0-alpha.76
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/package.json +5 -5
- package/src/App.focus.test.ts +147 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.test.tsx +11 -8
- package/src/App.tsx +476 -220
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +168 -123
- package/src/components/AppShell.brand.test.tsx +1 -1
- package/src/components/AppShell.tsx +14 -1
- package/src/components/ChangesColumn.tsx +40 -12
- package/src/components/ChangesFileTree.test.tsx +61 -0
- package/src/components/ConfirmDialog.tsx +8 -1
- package/src/components/DiffContentView.tsx +7 -1
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +10 -1
- package/src/components/PanelColumn.tsx +3 -0
- package/src/components/ProjectTreeView.test.tsx +39 -12
- package/src/components/ProjectTreeView.tsx +38 -10
- package/src/components/PromptDialog.test.tsx +61 -0
- package/src/components/PromptDialog.tsx +16 -3
- package/src/components/RootOverlay.stories.tsx +1 -0
- package/src/components/RootOverlay.tsx +19 -6
- package/src/components/SettingsDialog.stories.tsx +3 -3
- package/src/components/SettingsDialog.tsx +3 -4
- package/src/components/ThreadFooter.test.tsx +12 -7
- package/src/components/ThreadFooter.tsx +18 -4
- package/src/components/ThreadTree.tsx +79 -36
- package/src/components/ThreadView.slash.test.tsx +36 -0
- package/src/components/ThreadView.tsx +7 -1
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +60 -51
- package/src/components/menu-control.stories.tsx +35 -0
- package/src/components/menu-control.tsx +43 -0
- package/src/components/primitives/Dialog.test.tsx +37 -0
- package/src/components/primitives/Dialog.tsx +5 -0
- package/src/components/primitives/icons.ts +1 -1
- package/src/components/quick-actions-editor.stories.tsx +2 -0
- package/src/components/quick-actions-editor.test.tsx +35 -1
- package/src/components/quick-actions-editor.tsx +23 -22
- package/src/components/session-tree.test.ts +3 -3
- package/src/components/session-tree.ts +1 -1
- package/src/config.ts +1 -0
- package/src/diff-intraline.ts +26 -5
- package/src/perf/otlp-trace.test.ts +116 -0
- package/src/perf/otlp-trace.ts +130 -0
- package/src/perf/perf-report.ts +17 -0
- package/src/perf/perf-timings.test.ts +18 -0
- package/src/perf/perf-timings.ts +38 -0
- package/src/run.ts +22 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.ts +4 -3
- package/src/use-annotation-surface.tsx +22 -0
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +9 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
package/src/diff-intraline.ts
CHANGED
|
@@ -83,12 +83,30 @@ export function intralineRunsByRow(rows: DiffRow[]): Map<number, IntralineRun[]>
|
|
|
83
83
|
/** Lines this similar (word-set overlap) are treated as the same line edited. */
|
|
84
84
|
const SIMILAR_MIN_WORD_OVERLAP = 0.3;
|
|
85
85
|
|
|
86
|
+
/** Lower-cased word set of a line for the similarity gate, cached per distinct line within a block so
|
|
87
|
+
* the LCS grid tokenizes each line once instead of once per comparison. */
|
|
88
|
+
function lineWordSetCache(): (text: string) => ReadonlySet<string> {
|
|
89
|
+
const cache = new Map<string, ReadonlySet<string>>();
|
|
90
|
+
|
|
91
|
+
return (text) => {
|
|
92
|
+
const cached = cache.get(text);
|
|
93
|
+
if (cached !== undefined) return cached;
|
|
94
|
+
const words = new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
|
|
95
|
+
cache.set(text, words);
|
|
96
|
+
|
|
97
|
+
return words;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
86
101
|
/** Whether two lines are the same line edited, by case-insensitive word-set
|
|
87
102
|
* overlap - the matching gate, distinct from the case-sensitive word diff. */
|
|
88
|
-
function linesSimilar(
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
103
|
+
function linesSimilar(
|
|
104
|
+
wordsOf: (text: string) => ReadonlySet<string>,
|
|
105
|
+
oldText: string,
|
|
106
|
+
newText: string,
|
|
107
|
+
): boolean {
|
|
108
|
+
const oldWords = wordsOf(oldText);
|
|
109
|
+
const newWords = wordsOf(newText);
|
|
92
110
|
|
|
93
111
|
if (oldWords.size === 0 && newWords.size === 0) return true;
|
|
94
112
|
let intersection = 0;
|
|
@@ -112,8 +130,11 @@ function alignedPairs(deletionTexts: string[], additionTexts: string[]): Array<[
|
|
|
112
130
|
const pairs: Array<[number, number]> = [];
|
|
113
131
|
let deletionOffset = 0;
|
|
114
132
|
let additionOffset = 0;
|
|
133
|
+
const wordsOf = lineWordSetCache();
|
|
115
134
|
|
|
116
|
-
for (const op of lcsDiff(deletionTexts, additionTexts,
|
|
135
|
+
for (const op of lcsDiff(deletionTexts, additionTexts, (deletionText, additionText) =>
|
|
136
|
+
linesSimilar(wordsOf, deletionText, additionText),
|
|
137
|
+
)) {
|
|
117
138
|
if (op.kind === "ctx") {
|
|
118
139
|
pairs.push([deletionOffset, additionOffset]);
|
|
119
140
|
deletionOffset++;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildOtlpTracePayload,
|
|
4
|
+
phaseMarksToSpans,
|
|
5
|
+
postOtlpTrace,
|
|
6
|
+
type TraceSpan,
|
|
7
|
+
} from "./otlp-trace";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const span: TraceSpan = {
|
|
14
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
15
|
+
spanId: "0123456789abcdef",
|
|
16
|
+
name: "cueloop.interaction",
|
|
17
|
+
startTimeUnixNano: "1000",
|
|
18
|
+
endTimeUnixNano: "2000",
|
|
19
|
+
attributes: [
|
|
20
|
+
{ key: "terminal.trigger", value: { stringValue: "key:j" } },
|
|
21
|
+
{ key: "terminal.frame.budget_ms", value: { doubleValue: 16.7 } },
|
|
22
|
+
{ key: "terminal.backpressure", value: { boolValue: true } },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
test("buildOtlpTracePayload carries service.name and maps scalar attributes to OTLP values", () => {
|
|
27
|
+
const payload = buildOtlpTracePayload("cueloop-client", [span]);
|
|
28
|
+
|
|
29
|
+
expect(payload).toEqual({
|
|
30
|
+
resourceSpans: [
|
|
31
|
+
{
|
|
32
|
+
resource: {
|
|
33
|
+
attributes: [{ key: "service.name", value: { stringValue: "cueloop-client" } }],
|
|
34
|
+
},
|
|
35
|
+
scopeSpans: [
|
|
36
|
+
{
|
|
37
|
+
spans: [
|
|
38
|
+
{
|
|
39
|
+
traceId: "0123456789abcdef0123456789abcdef",
|
|
40
|
+
spanId: "0123456789abcdef",
|
|
41
|
+
parentSpanId: "",
|
|
42
|
+
name: "cueloop.interaction",
|
|
43
|
+
startTimeUnixNano: "1000",
|
|
44
|
+
endTimeUnixNano: "2000",
|
|
45
|
+
attributes: [
|
|
46
|
+
{ key: "terminal.trigger", value: { stringValue: "key:j" } },
|
|
47
|
+
{ key: "terminal.frame.budget_ms", value: { doubleValue: 16.7 } },
|
|
48
|
+
{ key: "terminal.backpressure", value: { boolValue: true } },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("phaseMarksToSpans builds a scope root with one child span per phase under one trace", () => {
|
|
60
|
+
const spans = phaseMarksToSpans("startup", [
|
|
61
|
+
{ label: "renderer", elapsedMs: 10 },
|
|
62
|
+
{ label: "firstFrame", elapsedMs: 20 },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
expect(spans).toHaveLength(3);
|
|
66
|
+
const [root, first, second] = spans;
|
|
67
|
+
expect(root!.name).toBe("cueloop.startup");
|
|
68
|
+
expect(root!.parentSpanId).toBeUndefined();
|
|
69
|
+
expect(first!.name).toBe("cueloop.startup.renderer");
|
|
70
|
+
expect(first!.parentSpanId).toBe(root!.spanId);
|
|
71
|
+
expect(second!.parentSpanId).toBe(root!.spanId);
|
|
72
|
+
expect(new Set(spans.map((each) => each.traceId)).size).toBe(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("postOtlpTrace sends no request when the endpoint is unset", async () => {
|
|
76
|
+
const requests: string[] = [];
|
|
77
|
+
const server = Bun.serve({
|
|
78
|
+
port: 0,
|
|
79
|
+
fetch(request) {
|
|
80
|
+
requests.push(request.url);
|
|
81
|
+
|
|
82
|
+
return new Response("{}");
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
await postOtlpTrace("cueloop-client", [span]);
|
|
87
|
+
} finally {
|
|
88
|
+
await server.stop(true);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
expect(requests).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("postOtlpTrace POSTs OTLP JSON to /v1/traces when the endpoint is set", async () => {
|
|
95
|
+
const requests: string[] = [];
|
|
96
|
+
const server = Bun.serve({
|
|
97
|
+
port: 0,
|
|
98
|
+
async fetch(request) {
|
|
99
|
+
requests.push(`${request.method} ${new URL(request.url).pathname}\n${await request.text()}`);
|
|
100
|
+
|
|
101
|
+
return new Response("{}");
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
// a trailing slash must not double up into "//v1/traces"
|
|
105
|
+
process.env.OTEL_EXPORTER_OTLP_ENDPOINT = `http://127.0.0.1:${server.port}/`;
|
|
106
|
+
try {
|
|
107
|
+
await postOtlpTrace("cueloop-client", [span]);
|
|
108
|
+
} finally {
|
|
109
|
+
await server.stop(true);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
expect(requests).toHaveLength(1);
|
|
113
|
+
expect(requests[0]).toContain("POST /v1/traces");
|
|
114
|
+
expect(requests[0]).toContain('"service.name"');
|
|
115
|
+
expect(requests[0]).toContain("cueloop.interaction");
|
|
116
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ship perf spans to a local OTLP/HTTP trace viewer (kundi) so an interaction's phases read as a
|
|
3
|
+
* callstack. Every export is a no-op unless OTEL_EXPORTER_OTLP_ENDPOINT is set. Tracing never throws
|
|
4
|
+
* into the app: a failed POST is swallowed. Attributes are scalar-only, matching the viewer's decoder.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PhaseMark } from "./perf-timings";
|
|
8
|
+
|
|
9
|
+
export type SpanAttributeValue =
|
|
10
|
+
| { stringValue: string }
|
|
11
|
+
| { doubleValue: number }
|
|
12
|
+
| { boolValue: boolean };
|
|
13
|
+
|
|
14
|
+
export interface SpanAttribute {
|
|
15
|
+
key: string;
|
|
16
|
+
value: SpanAttributeValue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TraceSpan {
|
|
20
|
+
traceId: string;
|
|
21
|
+
spanId: string;
|
|
22
|
+
parentSpanId?: string;
|
|
23
|
+
name: string;
|
|
24
|
+
startTimeUnixNano: string;
|
|
25
|
+
endTimeUnixNano: string;
|
|
26
|
+
attributes?: readonly SpanAttribute[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function randomHex(bytes: number): string {
|
|
30
|
+
const buffer = new Uint8Array(bytes);
|
|
31
|
+
crypto.getRandomValues(buffer);
|
|
32
|
+
|
|
33
|
+
return Array.from(buffer, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A 16-byte trace id as 32 lowercase hex chars, per the OTLP wire format. */
|
|
37
|
+
export function newTraceId(): string {
|
|
38
|
+
return randomHex(16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** An 8-byte span id as 16 lowercase hex chars, per the OTLP wire format. */
|
|
42
|
+
export function newSpanId(): string {
|
|
43
|
+
return randomHex(8);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build the OTLP/JSON `resourceSpans` body the trace viewer's `/v1/traces` decoder reads. */
|
|
47
|
+
export function buildOtlpTracePayload(serviceName: string, spans: readonly TraceSpan[]) {
|
|
48
|
+
return {
|
|
49
|
+
resourceSpans: [
|
|
50
|
+
{
|
|
51
|
+
resource: {
|
|
52
|
+
attributes: [{ key: "service.name", value: { stringValue: serviceName } }],
|
|
53
|
+
},
|
|
54
|
+
scopeSpans: [
|
|
55
|
+
{
|
|
56
|
+
spans: spans.map((span) => ({
|
|
57
|
+
traceId: span.traceId,
|
|
58
|
+
spanId: span.spanId,
|
|
59
|
+
// the viewer reads a missing parent as root; an empty string is the same, without a conditional key
|
|
60
|
+
parentSpanId: span.parentSpanId ?? "",
|
|
61
|
+
name: span.name,
|
|
62
|
+
startTimeUnixNano: span.startTimeUnixNano,
|
|
63
|
+
endTimeUnixNano: span.endTimeUnixNano,
|
|
64
|
+
attributes: (span.attributes ?? []).map((attribute) => ({
|
|
65
|
+
key: attribute.key,
|
|
66
|
+
value: attribute.value,
|
|
67
|
+
})),
|
|
68
|
+
})),
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nanosFromPerfMs(milliseconds: number): string {
|
|
77
|
+
const originNs = BigInt(Math.round(performance.timeOrigin * 1e6));
|
|
78
|
+
|
|
79
|
+
return String(originNs + BigInt(Math.round(milliseconds * 1e6)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Turn drained phase marks into a span tree: a `scope` root with one child span per phase, in order. */
|
|
83
|
+
export function phaseMarksToSpans(scope: string, marks: readonly PhaseMark[]): TraceSpan[] {
|
|
84
|
+
const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
|
|
85
|
+
const endMs = performance.now();
|
|
86
|
+
const startMs = endMs - totalMs;
|
|
87
|
+
const traceId = newTraceId();
|
|
88
|
+
const rootSpanId = newSpanId();
|
|
89
|
+
const root: TraceSpan = {
|
|
90
|
+
traceId,
|
|
91
|
+
spanId: rootSpanId,
|
|
92
|
+
name: `cueloop.${scope}`,
|
|
93
|
+
startTimeUnixNano: nanosFromPerfMs(startMs),
|
|
94
|
+
endTimeUnixNano: nanosFromPerfMs(endMs),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let cursorMs = startMs;
|
|
98
|
+
const children = marks.map((mark) => {
|
|
99
|
+
const phaseStartMs = cursorMs;
|
|
100
|
+
cursorMs += mark.elapsedMs;
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
traceId,
|
|
104
|
+
spanId: newSpanId(),
|
|
105
|
+
parentSpanId: rootSpanId,
|
|
106
|
+
name: `cueloop.${scope}.${mark.label}`,
|
|
107
|
+
startTimeUnixNano: nanosFromPerfMs(phaseStartMs),
|
|
108
|
+
endTimeUnixNano: nanosFromPerfMs(cursorMs),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return [root, ...children];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** POST the spans to `${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces` as OTLP/JSON; a no-op when the endpoint is unset. */
|
|
116
|
+
export async function postOtlpTrace(
|
|
117
|
+
serviceName: string,
|
|
118
|
+
spans: readonly TraceSpan[],
|
|
119
|
+
): Promise<void> {
|
|
120
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
121
|
+
if (endpoint === undefined || spans.length === 0) return;
|
|
122
|
+
|
|
123
|
+
// strip a trailing slash so an endpoint like "http://host:4318/" does not become "//v1/traces"
|
|
124
|
+
const base = endpoint.replace(/\/+$/, "");
|
|
125
|
+
await fetch(`${base}/v1/traces`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { "content-type": "application/json" },
|
|
128
|
+
body: JSON.stringify(buildOtlpTracePayload(serviceName, spans)),
|
|
129
|
+
}).catch(() => undefined);
|
|
130
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report the drained perf marks for a scope two ways from one set of instrumentation: a phase block on
|
|
3
|
+
* stderr when CUELOOP_PERF=1, and a kundi span tree when the OTLP endpoint is set. No-op when neither is on.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { formatPerfBlock, takePerfMarks } from "./perf-timings";
|
|
7
|
+
import { phaseMarksToSpans, postOtlpTrace } from "./otlp-trace";
|
|
8
|
+
|
|
9
|
+
const PERF_SERVICE_NAME = "cueloop-client";
|
|
10
|
+
|
|
11
|
+
export function reportPerfMarks(scope: string): void {
|
|
12
|
+
const marks = takePerfMarks();
|
|
13
|
+
if (marks.length === 0) return;
|
|
14
|
+
|
|
15
|
+
if (process.env.CUELOOP_PERF === "1") process.stderr.write(formatPerfBlock(scope, marks));
|
|
16
|
+
void postOtlpTrace(PERF_SERVICE_NAME, phaseMarksToSpans(scope, marks));
|
|
17
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { formatPerfBlock } from "./perf-timings";
|
|
3
|
+
|
|
4
|
+
test("formatPerfBlock renders labeled rows and a summed TOTAL", () => {
|
|
5
|
+
const block = formatPerfBlock("startup", [
|
|
6
|
+
{ label: "connect", elapsedMs: 12.34 },
|
|
7
|
+
{ label: "firstRender", elapsedMs: 7.66 },
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
expect(block).toContain("--- perf startup ---");
|
|
11
|
+
expect(block).toContain("connect 12.3ms");
|
|
12
|
+
expect(block).toContain("firstRender 7.7ms");
|
|
13
|
+
expect(block).toContain("TOTAL 20.0ms");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("formatPerfBlock totals to zero for no marks", () => {
|
|
17
|
+
expect(formatPerfBlock("startup", [])).toContain("TOTAL 0.0ms");
|
|
18
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Startup phase timing for the perf harness: records elapsed ms between marks and flushes a
|
|
2
|
+
* labeled block to stderr. Every entry point is a no-op unless the CUELOOP_PERF env var is "1". */
|
|
3
|
+
|
|
4
|
+
export interface PhaseMark {
|
|
5
|
+
label: string;
|
|
6
|
+
elapsedMs: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const phaseMarks: PhaseMark[] = [];
|
|
10
|
+
let lastMarkAt: number | null = null;
|
|
11
|
+
|
|
12
|
+
/** Marks record when the stderr timer (CUELOOP_PERF=1) or the kundi exporter (OTEL endpoint) is on. */
|
|
13
|
+
function perfInstrumentationEnabled(): boolean {
|
|
14
|
+
return process.env.CUELOOP_PERF === "1" || process.env.OTEL_EXPORTER_OTLP_ENDPOINT !== undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Record the elapsed ms since the previous mark (or process start) under `label`. */
|
|
18
|
+
export function perfMark(label: string): void {
|
|
19
|
+
if (!perfInstrumentationEnabled()) return;
|
|
20
|
+
const now = performance.now();
|
|
21
|
+
phaseMarks.push({ label, elapsedMs: now - (lastMarkAt ?? 0) });
|
|
22
|
+
lastMarkAt = now;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Drain the recorded marks for a reporter to format or export; empties the buffer for the next scope. */
|
|
26
|
+
export function takePerfMarks(): PhaseMark[] {
|
|
27
|
+
lastMarkAt = null;
|
|
28
|
+
|
|
29
|
+
return phaseMarks.splice(0, phaseMarks.length);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Render phase deltas as the stderr block: a header, one indented row per mark, a summed TOTAL. */
|
|
33
|
+
export function formatPerfBlock(scope: string, marks: readonly PhaseMark[]): string {
|
|
34
|
+
const totalMs = marks.reduce((sum, mark) => sum + mark.elapsedMs, 0);
|
|
35
|
+
const rows = marks.map((mark) => ` ${mark.label} ${mark.elapsedMs.toFixed(1)}ms`);
|
|
36
|
+
|
|
37
|
+
return [`--- perf ${scope} ---`, ...rows, ` TOTAL ${totalMs.toFixed(1)}ms`, ""].join("\n");
|
|
38
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { createCliRenderer } from "@opentui/core";
|
|
|
5
5
|
import { createRoot } from "@opentui/react";
|
|
6
6
|
import { App } from "./App";
|
|
7
7
|
import { loadConfig } from "./config";
|
|
8
|
+
import { perfMark } from "./perf/perf-timings";
|
|
9
|
+
import { reportPerfMarks } from "./perf/perf-report";
|
|
8
10
|
import { defaultLayout, type LaunchLayout } from "./launch-layout";
|
|
9
11
|
|
|
10
12
|
export interface RunClientOptions {
|
|
@@ -14,8 +16,9 @@ export interface RunClientOptions {
|
|
|
14
16
|
layout?: LaunchLayout;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
/** OSC background query budget:
|
|
18
|
-
|
|
19
|
+
/** OSC background query budget: long enough for a terminal to answer, short enough that one that never
|
|
20
|
+
* does not stall the launch; a non-answering terminal falls back to dark after this. */
|
|
21
|
+
const THEME_QUERY_TIMEOUT_MS = 100;
|
|
19
22
|
|
|
20
23
|
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
21
24
|
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
@@ -26,8 +29,20 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
26
29
|
options.layout ??
|
|
27
30
|
(options.sessionId === undefined ? (loadConfig().ui.layout ?? defaultLayout()) : undefined);
|
|
28
31
|
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
32
|
+
// a full screen of measured elements each holds a frame listener; lift the default-10 ceiling so a
|
|
33
|
+
// busy view does not trip a false leak warning, while a runaway subscription still would
|
|
34
|
+
renderer.setMaxListeners(64);
|
|
35
|
+
perfMark("renderer");
|
|
36
|
+
// re-assert mouse reporting on focus-in: a multiplexer can drop it, stranding the pointer in native selection
|
|
37
|
+
renderer.on("focus", () => {
|
|
38
|
+
renderer.useMouse = false;
|
|
39
|
+
renderer.useMouse = true;
|
|
40
|
+
});
|
|
41
|
+
// block first paint on the theme query only briefly, so an answering terminal paints its real theme
|
|
42
|
+
// with no flash while a non-answering one falls back to dark within the budget
|
|
29
43
|
const appearance =
|
|
30
44
|
(await renderer.waitForThemeMode(THEME_QUERY_TIMEOUT_MS).catch(() => null)) ?? "dark";
|
|
45
|
+
perfMark("themeQuery");
|
|
31
46
|
|
|
32
47
|
return new Promise<number>((resolve) => {
|
|
33
48
|
let exited = false;
|
|
@@ -53,7 +68,12 @@ export async function runClient(options: RunClientOptions): Promise<number> {
|
|
|
53
68
|
appearance,
|
|
54
69
|
layout,
|
|
55
70
|
onExit: shutdown,
|
|
71
|
+
onReady: () => {
|
|
72
|
+
perfMark("firstFrame");
|
|
73
|
+
reportPerfMarks("startup");
|
|
74
|
+
},
|
|
56
75
|
}),
|
|
57
76
|
);
|
|
77
|
+
perfMark("render");
|
|
58
78
|
});
|
|
59
79
|
}
|
|
@@ -1,8 +1,40 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_LEADER,
|
|
4
|
+
leaderHint,
|
|
5
|
+
matchesLeader,
|
|
6
|
+
resolveLeaderCommand,
|
|
7
|
+
resolveThreadChord,
|
|
8
|
+
} from "./thread-chords";
|
|
3
9
|
|
|
4
10
|
const owner = { composing: false, isOwner: true, resolved: false, treeActive: false };
|
|
5
11
|
|
|
12
|
+
describe("the command leader", () => {
|
|
13
|
+
test("matchesLeader recognizes the configured chord and nothing else", () => {
|
|
14
|
+
expect(matchesLeader({ ctrl: true, name: "g" }, [DEFAULT_LEADER])).toBe(true);
|
|
15
|
+
expect(matchesLeader({ ctrl: true, name: "G" }, [DEFAULT_LEADER])).toBe(true);
|
|
16
|
+
expect(matchesLeader({ name: "g" }, [DEFAULT_LEADER])).toBe(false);
|
|
17
|
+
expect(matchesLeader({ ctrl: true, name: "x" }, [DEFAULT_LEADER])).toBe(false);
|
|
18
|
+
expect(matchesLeader({ ctrl: true, meta: true, name: "g" }, [DEFAULT_LEADER])).toBe(false);
|
|
19
|
+
expect(matchesLeader({ name: "," }, [","])).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("leaderHint renders the glyph plus a trailing space", () => {
|
|
23
|
+
expect(leaderHint(["ctrl+g"])).toBe("⌃g ");
|
|
24
|
+
expect(leaderHint([","])).toBe(", ");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("a key after the leader runs the same intent as its Option chord", () => {
|
|
28
|
+
const diff = { ...owner, isDiff: true };
|
|
29
|
+
|
|
30
|
+
expect(resolveLeaderCommand({ name: "x" }, diff)).toEqual({ type: "rejectChange" });
|
|
31
|
+
expect(resolveLeaderCommand({ name: "X" }, diff)).toEqual({ type: "rejectHunk" });
|
|
32
|
+
expect(resolveLeaderCommand({ name: "x" }, owner)).toEqual({ type: "cut" });
|
|
33
|
+
expect(resolveLeaderCommand({ name: "return" }, owner)).toEqual({ type: "openSubmit" });
|
|
34
|
+
expect(resolveLeaderCommand({ name: "x" }, { ...owner, composing: true })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
6
38
|
describe("resolveThreadChord", () => {
|
|
7
39
|
test("ctrl+enter opens the submit overlay for an owner with an open review", () => {
|
|
8
40
|
// Assert: the terminal-safe chord and the cmd forms all submit
|
package/src/thread-chords.ts
CHANGED
|
@@ -28,16 +28,70 @@ export interface ThreadChordContext {
|
|
|
28
28
|
isDiff?: boolean;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
]
|
|
31
|
+
export const DEFAULT_LEADER = "ctrl+g";
|
|
32
|
+
|
|
33
|
+
export function leaderCombosFor(configured: readonly string[] | undefined): readonly string[] {
|
|
34
|
+
return configured ?? [DEFAULT_LEADER];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MOD_GLYPHS = new Map<string, string>([
|
|
38
|
+
["ctrl", "⌃"],
|
|
39
|
+
["control", "⌃"],
|
|
40
|
+
["meta", "⌥"],
|
|
41
|
+
["alt", "⌥"],
|
|
42
|
+
["option", "⌥"],
|
|
43
|
+
["shift", "⇧"],
|
|
44
|
+
["cmd", "⌘"],
|
|
45
|
+
["super", "⌘"],
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
function comboParts(combo: string) {
|
|
49
|
+
const parts = combo.split("+");
|
|
50
|
+
|
|
51
|
+
return { mods: parts.slice(0, -1).map((part) => part.toLowerCase()), name: parts.at(-1) ?? "" };
|
|
52
|
+
}
|
|
39
53
|
|
|
40
|
-
|
|
54
|
+
export function leaderHint(combos: readonly string[]): string {
|
|
55
|
+
const { mods, name } = comboParts(combos[0] ?? DEFAULT_LEADER);
|
|
56
|
+
|
|
57
|
+
return `${mods.map((mod) => MOD_GLYPHS.get(mod) ?? mod).join("")}${name} `;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function matchesLeader(
|
|
61
|
+
key: { ctrl?: boolean; meta?: boolean; shift?: boolean; super?: boolean; name: string },
|
|
62
|
+
combos: readonly string[],
|
|
63
|
+
): boolean {
|
|
64
|
+
return combos.some((combo) => {
|
|
65
|
+
const { mods, name } = comboParts(combo);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
key.name.toLowerCase() === name.toLowerCase() &&
|
|
69
|
+
Boolean(key.ctrl) === (mods.includes("ctrl") || mods.includes("control")) &&
|
|
70
|
+
Boolean(key.shift) === mods.includes("shift") &&
|
|
71
|
+
Boolean(key.super) === (mods.includes("cmd") || mods.includes("super")) &&
|
|
72
|
+
Boolean(key.meta) ===
|
|
73
|
+
(mods.includes("meta") || mods.includes("alt") || mods.includes("option"))
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ChordEntry {
|
|
79
|
+
keys: string;
|
|
80
|
+
label: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The diff-review chords: the leader (or Option) plus a letter, on the caret's row or file. */
|
|
84
|
+
export function diffChordEntries(hint: string): ChordEntry[] {
|
|
85
|
+
return [
|
|
86
|
+
{ keys: `${hint}x / ${hint}X`, label: "reject the change / the hunk" },
|
|
87
|
+
{ keys: `${hint}u`, label: "restore the last rejection" },
|
|
88
|
+
{ keys: `${hint}c`, label: "collapse the file to its band" },
|
|
89
|
+
{ keys: `${hint}d`, label: "split / stacked (when wide)" },
|
|
90
|
+
{ keys: `${hint}k`, label: "start the guided walk" },
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The session chords stay on Ctrl, which terminals deliver reliably. */
|
|
41
95
|
export const THREAD_CHORD_ENTRIES = [
|
|
42
96
|
{ keys: "⌃enter", label: "submit the review" },
|
|
43
97
|
{ keys: "⌃e", label: "edit in $EDITOR" },
|
|
@@ -45,27 +99,31 @@ export const THREAD_CHORD_ENTRIES = [
|
|
|
45
99
|
{ keys: "⌃r", label: "cycle the rail" },
|
|
46
100
|
] as const;
|
|
47
101
|
|
|
48
|
-
/** The rail and curation chords:
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
102
|
+
/** The rail and curation chords: the leader plus the plan sheet's old letter. */
|
|
103
|
+
export function railChordEntries(hint: string): ChordEntry[] {
|
|
104
|
+
return [
|
|
105
|
+
{ keys: `${hint}n / ${hint}p`, label: "next / previous card" },
|
|
106
|
+
{ keys: `${hint}e`, label: "edit the card" },
|
|
107
|
+
{ keys: `${hint}⌫`, label: "delete the card" },
|
|
108
|
+
{ keys: `${hint}r`, label: "rename the author" },
|
|
109
|
+
{ keys: `${hint}x`, label: "cut the block" },
|
|
110
|
+
{ keys: `${hint}u`, label: "restore the last cut" },
|
|
111
|
+
{ keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
|
|
112
|
+
];
|
|
113
|
+
}
|
|
58
114
|
|
|
59
|
-
/** The tree chords:
|
|
60
|
-
export
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
115
|
+
/** The tree chords: the leader plus a letter, all on the rail's Tree tab. */
|
|
116
|
+
export function treeChordEntries(hint: string): ChordEntry[] {
|
|
117
|
+
return [
|
|
118
|
+
{ keys: `${hint}t`, label: "show / hide the tree" },
|
|
119
|
+
{ keys: `${hint}n / ${hint}p`, label: "next / previous entry" },
|
|
120
|
+
{ keys: `${hint}g`, label: "go to the entry" },
|
|
121
|
+
{ keys: `${hint}b`, label: "branch off the tip" },
|
|
122
|
+
{ keys: `${hint}l`, label: "label a checkpoint" },
|
|
123
|
+
{ keys: `${hint}f`, label: "fork the path" },
|
|
124
|
+
{ keys: `${hint}h`, label: "fork and hand off" },
|
|
125
|
+
];
|
|
126
|
+
}
|
|
69
127
|
|
|
70
128
|
export function resolveThreadChord(
|
|
71
129
|
key: ThreadChordKey,
|
|
@@ -89,6 +147,33 @@ export function resolveThreadChord(
|
|
|
89
147
|
return null;
|
|
90
148
|
}
|
|
91
149
|
|
|
150
|
+
export function resolveLeaderCommand(
|
|
151
|
+
key: ThreadChordKey,
|
|
152
|
+
context: ThreadChordContext,
|
|
153
|
+
): Intent | null {
|
|
154
|
+
if (context.composing) return null;
|
|
155
|
+
if (key.name === "return" || key.name === "enter") {
|
|
156
|
+
if (!context.isOwner) return READ_ONLY;
|
|
157
|
+
|
|
158
|
+
return context.resolved ? null : { type: "openSubmit" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
(context.isDiff && resolveDiffChord(key.name, context)) || resolveRailChord(key.name, context)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function dispatchLeaderCommand(
|
|
167
|
+
key: { name: string; shift?: boolean },
|
|
168
|
+
context: ThreadChordContext,
|
|
169
|
+
dispatch: (intent: Intent) => void,
|
|
170
|
+
): void {
|
|
171
|
+
const name = key.shift ? key.name.toUpperCase() : key.name;
|
|
172
|
+
const chord = resolveLeaderCommand({ name }, context);
|
|
173
|
+
|
|
174
|
+
if (chord) dispatch(chord);
|
|
175
|
+
}
|
|
176
|
+
|
|
92
177
|
/** The diff's row and file chords; null lets the rail chords answer the letter. */
|
|
93
178
|
function resolveDiffChord(name: string, context: ThreadChordContext): Intent | null {
|
|
94
179
|
switch (name) {
|
package/src/thread-controller.ts
CHANGED
|
@@ -648,9 +648,10 @@ class Controller implements ReviewController {
|
|
|
648
648
|
// eager: capture the live working-tree diff so the Changes navigator and its file tabs render a real diff
|
|
649
649
|
if (this.client?.repoDiff !== undefined) {
|
|
650
650
|
const diff = await this.client.repoDiff(this.sidebarRepoRoot());
|
|
651
|
-
// a thread switch during the request would let this response overwrite the new
|
|
652
|
-
//
|
|
653
|
-
|
|
651
|
+
// a thread switch during the request would let this response overwrite the new thread's diff,
|
|
652
|
+
// showing changes from the wrong repo; compare the thread id, not the object, so an unrelated
|
|
653
|
+
// re-render that replaced the snapshot for the SAME thread does not drop its diff to "No changes"
|
|
654
|
+
if (this.snapshot.session?.id !== session?.id) return diff.files;
|
|
654
655
|
this.liveDiff = diff;
|
|
655
656
|
// rows() derive from the fresh patch; re-render so an open diff tab repaints
|
|
656
657
|
this.update({});
|