@cueloop/client 0.1.0-alpha.75 → 0.1.0-alpha.77
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.identity.test.tsx +78 -0
- package/src/App.regression.test.tsx +94 -0
- package/src/App.share-choice.test.tsx +105 -0
- package/src/App.test.tsx +11 -8
- package/src/App.toast.test.tsx +3 -1
- package/src/App.tsx +648 -221
- package/src/App.visual.test.tsx +57 -0
- package/src/App.workbench.test.tsx +41 -22
- package/src/app-screens.tsx +181 -123
- package/src/app-view-model.ts +1 -0
- package/src/components/AnnotatableFileView.tsx +9 -1
- package/src/components/AnnotationCards.tsx +22 -4
- 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 +12 -2
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tsx +84 -26
- package/src/components/GridTabContent.tsx +12 -1
- package/src/components/ManageAccessDialog.stories.tsx +36 -0
- package/src/components/ManageAccessDialog.test.tsx +94 -0
- package/src/components/ManageAccessDialog.tsx +120 -0
- 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/ShareChoiceDialog.stories.tsx +34 -0
- package/src/components/ShareChoiceDialog.test.tsx +103 -0
- package/src/components/ShareChoiceDialog.tsx +82 -0
- 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 +12 -2
- package/src/components/WelcomePlayground.tsx +2 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +147 -52
- 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-identity.test.ts +78 -0
- package/src/config.ts +45 -1
- package/src/diff-intraline.ts +26 -5
- package/src/github-identity.test.ts +58 -0
- package/src/github-identity.ts +41 -0
- package/src/intent-dispatch.test.ts +17 -2
- package/src/intent-dispatch.ts +7 -1
- package/src/keymap.ts +1 -0
- 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/share.ts +10 -2
- package/src/thread-chords.test.ts +33 -1
- package/src/thread-chords.ts +114 -29
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +1 -0
- package/src/thread-controller.share.test.ts +36 -2
- package/src/thread-controller.tree.test.ts +1 -0
- package/src/thread-controller.ts +18 -3
- package/src/use-annotation-surface.tsx +32 -2
- package/src/use-frame-measure.ts +4 -1
- package/src/use-settings-dialog.tsx +33 -7
- package/src/visual/contrast.test.ts +35 -0
- package/src/visual/contrast.ts +33 -0
|
@@ -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
|
}
|
package/src/share.ts
CHANGED
|
@@ -12,7 +12,13 @@ import {
|
|
|
12
12
|
SHARE_UPLOAD_USER,
|
|
13
13
|
packSessionBlob,
|
|
14
14
|
} from "@cueloop/daemon/share-blob";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
removalEntries,
|
|
17
|
+
viewFollowing,
|
|
18
|
+
type Annotation,
|
|
19
|
+
type ShareAccess,
|
|
20
|
+
type Thread,
|
|
21
|
+
} from "@cueloop/schema";
|
|
16
22
|
import { ThreadRecordSchema } from "@cueloop/daemon/validate";
|
|
17
23
|
import type { SharedMerge } from "@cueloop/daemon/client";
|
|
18
24
|
import * as v from "valibot";
|
|
@@ -102,11 +108,13 @@ export function mergeFromShare(remote: Thread): SharedMerge {
|
|
|
102
108
|
export async function pushShare(
|
|
103
109
|
shareId: string,
|
|
104
110
|
annotations: Array<Omit<Annotation, "createdAt">>,
|
|
111
|
+
access?: ShareAccess,
|
|
105
112
|
target: ShareTarget = {},
|
|
106
113
|
): Promise<void> {
|
|
114
|
+
const payload = access ? { shareId, annotations, access } : { shareId, annotations };
|
|
107
115
|
const { stderr, code } = await runShareSsh(
|
|
108
116
|
"cueloop-push",
|
|
109
|
-
Buffer.from(JSON.stringify(
|
|
117
|
+
Buffer.from(JSON.stringify(payload)),
|
|
110
118
|
target,
|
|
111
119
|
);
|
|
112
120
|
|
|
@@ -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) {
|
|
@@ -109,6 +109,7 @@ function fakeClient(initial: Thread, sink: WorkingCopySink): SessionClient {
|
|
|
109
109
|
|
|
110
110
|
return session;
|
|
111
111
|
}),
|
|
112
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
112
113
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
113
114
|
sessionBranch: unimplemented("sessionBranch"),
|
|
114
115
|
sessionSwitch: unimplemented("sessionSwitch"),
|
|
@@ -73,6 +73,7 @@ function fakeClient(session: Thread): SessionClient {
|
|
|
73
73
|
sessionCutBlock: unimplemented("sessionCutBlock"),
|
|
74
74
|
sessionRestoreBlock: unimplemented("sessionRestoreBlock"),
|
|
75
75
|
sessionCurate: unimplemented("sessionCurate"),
|
|
76
|
+
sessionSetAccess: unimplemented("sessionSetAccess"),
|
|
76
77
|
sessionNavigate: unimplemented("sessionNavigate"),
|
|
77
78
|
sessionBranch: unimplemented("sessionBranch"),
|
|
78
79
|
sessionSwitch: unimplemented("sessionSwitch"),
|