@anscribe/opentui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +8 -0
- package/dist/install-Bbw_EVFu.d.mts +18 -0
- package/dist/install-DYrt2HTN.mjs +711 -0
- package/dist/react/preload.d.mts +1 -0
- package/dist/react/preload.mjs +2 -0
- package/dist/react.d.mts +6 -0
- package/dist/react.mjs +42 -0
- package/dist/sink-registry-DAlYVk2U.mjs +16 -0
- package/dist/sinks.d.mts +13 -0
- package/dist/sinks.mjs +2 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 msmps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# @anscribe/opentui
|
|
2
|
+
|
|
3
|
+
> Capture live UI from an [OpenTUI](https://github.com/sst/opentui) app and hand it to an agent. Clipboard by default, opt-in MCP queue.
|
|
4
|
+
|
|
5
|
+
`installCapture` installs an Anscribe Capture Mode overlay on an OpenTUI `CliRenderer`. A headless `<Anscribe />` component is exposed for OpenTUI React apps.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @anscribe/opentui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To also persist Captures into a project-local queue that agents pull from, install [`@anscribe/mcp`](https://www.npmjs.com/package/@anscribe/mcp).
|
|
14
|
+
|
|
15
|
+
## OpenTUI Core
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { installCapture } from "@anscribe/opentui";
|
|
19
|
+
import { createCliRenderer } from "@opentui/core";
|
|
20
|
+
|
|
21
|
+
const renderer = await createCliRenderer({ useMouse: true });
|
|
22
|
+
|
|
23
|
+
const capture = installCapture(renderer, { keybinding: "ctrl+g" });
|
|
24
|
+
|
|
25
|
+
// On shutdown:
|
|
26
|
+
await capture.close();
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The returned handle exposes `dispose()` (sync) and `close()` (async). Call one before destroying the renderer so any in-flight sink writes complete.
|
|
30
|
+
|
|
31
|
+
## OpenTUI React
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import "@anscribe/opentui/react/preload";
|
|
35
|
+
|
|
36
|
+
import { Anscribe } from "@anscribe/opentui/react";
|
|
37
|
+
import { createCliRenderer } from "@opentui/core";
|
|
38
|
+
import { createRoot } from "@opentui/react";
|
|
39
|
+
|
|
40
|
+
function App() {
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<Anscribe keybinding="ctrl+g" />
|
|
44
|
+
<text id="save-action" content="Save" />
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const renderer = await createCliRenderer({ useMouse: true });
|
|
50
|
+
createRoot(renderer).render(<App />);
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`@anscribe/opentui/react/preload` **must be imported before `@opentui/react`**. It installs a React DevTools hook that lets Anscribe enrich Captures with `componentName` and `componentPath`. If the preload is missing or imported late, Capture Mode still works — React metadata is simply absent and `<Anscribe />` warns once in development.
|
|
54
|
+
|
|
55
|
+
The preload is a side-effect re-export of [`@anscribe/react/preload`](https://www.npmjs.com/package/@anscribe/react). Use the OpenTUI subpath here; reach for `@anscribe/react/preload` directly only if you're building a non-OpenTUI adapter.
|
|
56
|
+
|
|
57
|
+
## Capture Mode
|
|
58
|
+
|
|
59
|
+
| Key | Action |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `ctrl+g` | Enter Capture Mode (configurable via `keybinding`) |
|
|
62
|
+
| `tab` / `↓` / `→` / `j` | Next selectable renderable |
|
|
63
|
+
| `shift+tab` / `↑` / `←` / `k` | Previous renderable |
|
|
64
|
+
| `space` / `enter` | Toggle current selection |
|
|
65
|
+
| `backspace` / `delete` | Deselect current target |
|
|
66
|
+
| `a` | Open instruction prompt |
|
|
67
|
+
| `enter` (in prompt) | Save pending Capture |
|
|
68
|
+
| `esc` (in prompt) | Cancel draft, keep selection |
|
|
69
|
+
| `esc` / `q` | Exit Capture Mode |
|
|
70
|
+
|
|
71
|
+
With mouse input enabled, left-clicking a renderable selects it (clicks on text-node children resolve to the containing renderable). Capture Mode draws a translucent highlight over the current target. Normal app input is paused while Capture Mode is active and resumes on exit.
|
|
72
|
+
|
|
73
|
+
## Options
|
|
74
|
+
|
|
75
|
+
`installCapture(renderer, options?)` and `<Anscribe />` share the same options:
|
|
76
|
+
|
|
77
|
+
| Option | Type | Default | |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `keybinding` | `string` | `"ctrl+g"` | Entry shortcut |
|
|
80
|
+
| `highlightColor` | hex | — | Color of the current target highlight |
|
|
81
|
+
| `selectedColor` | hex | — | Color of selected targets |
|
|
82
|
+
|
|
83
|
+
## Clipboard handoff (always on)
|
|
84
|
+
|
|
85
|
+
Every committed Capture is written to the system clipboard via [OSC52](https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md#OSC-52) as markdown. Works over SSH, inside dev containers, and on terminals without native bindings or permission prompts.
|
|
86
|
+
|
|
87
|
+
The payload format:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
fix this row to use the new auth flow
|
|
91
|
+
|
|
92
|
+
<BoxRenderable id="settings-status"> "Status: unsaved preference changes"
|
|
93
|
+
in SettingsPanel (at src/settings.tsx:42)
|
|
94
|
+
in App (at src/index.tsx:10)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Sinks (opt-in fan-out)
|
|
98
|
+
|
|
99
|
+
Every committed Capture goes to the clipboard first; additional sinks fan out from there. The canonical sink is the MCP queue from [`@anscribe/mcp`](https://www.npmjs.com/package/@anscribe/mcp) — added with a single side-effect import at the top of your entry file:
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
import "@anscribe/mcp/sink";
|
|
103
|
+
|
|
104
|
+
import { installCapture } from "@anscribe/opentui";
|
|
105
|
+
// ...installCapture as usual; the sink is already wired.
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The side-effect module registers the sink with `@anscribe/opentui`'s internal registry. `installCapture` snapshots the registry at host install time, so the import must run before the first `installCapture` call.
|
|
109
|
+
|
|
110
|
+
### Custom and programmatic sinks
|
|
111
|
+
|
|
112
|
+
For dynamic registration (tests, multi-tenant runners, custom destinations) reach for `@anscribe/opentui/sinks`:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
import { registerCaptureSink, type CaptureSink } from "@anscribe/opentui/sinks";
|
|
116
|
+
|
|
117
|
+
const webhookSink: CaptureSink = {
|
|
118
|
+
name: "webhook",
|
|
119
|
+
write: async (capture) => {
|
|
120
|
+
await fetch("https://example.com/captures", {
|
|
121
|
+
method: "POST",
|
|
122
|
+
body: JSON.stringify(capture),
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
registerCaptureSink(webhookSink);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`CaptureSink` lives in `@anscribe/core`; the subpath here re-exports it for ergonomics. `resetCaptureSinks()` is also exported for test isolation — production code should not use it.
|
|
131
|
+
|
|
132
|
+
If a sink fails, the host's failure reporter logs the error tagged with the sink's name. The clipboard handoff has already happened, so the user-visible state stays consistent. The capture state machine has already committed the Capture; sink failures don't roll it back.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT © [msmps](https://github.com/msmps)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { n as InstallCaptureOptions, t as CaptureInstallation } from "./install-Bbw_EVFu.mjs";
|
|
2
|
+
import { Capture, CaptureId, CaptureMetadata, CaptureSink, CaptureStatus, CapturedTarget, CapturedTargetId, IsoTimestamp, SourceReference, TerminalCellBounds } from "@anscribe/core";
|
|
3
|
+
import { CliRenderer } from "@opentui/core";
|
|
4
|
+
|
|
5
|
+
//#region src/index.d.ts
|
|
6
|
+
declare function installCapture(renderer: CliRenderer, options?: InstallCaptureOptions): CaptureInstallation;
|
|
7
|
+
//#endregion
|
|
8
|
+
export { type Capture, type CaptureId, type CaptureInstallation, type CaptureMetadata, type CaptureSink, type CaptureStatus, type CapturedTarget, type CapturedTargetId, type InstallCaptureOptions, type IsoTimestamp, type SourceReference, type TerminalCellBounds, installCapture };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { t as installCaptureWithEnrichment } from "./install-DYrt2HTN.mjs";
|
|
2
|
+
import { CaptureMetadataEnrichment } from "@anscribe/core";
|
|
3
|
+
//#region src/index.ts
|
|
4
|
+
function installCapture(renderer, options = {}) {
|
|
5
|
+
return installCaptureWithEnrichment(renderer, options, CaptureMetadataEnrichment.live);
|
|
6
|
+
}
|
|
7
|
+
//#endregion
|
|
8
|
+
export { installCapture };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { CaptureMetadataEnrichment } from "@anscribe/core";
|
|
2
|
+
import { Layer } from "effect";
|
|
3
|
+
import { CliRenderer } from "@opentui/core";
|
|
4
|
+
|
|
5
|
+
//#region src/internal/install.d.ts
|
|
6
|
+
interface InstallCaptureOptions {
|
|
7
|
+
readonly keybinding?: string;
|
|
8
|
+
/** Hex color (e.g. "#ffd166") drawn over the target under the cursor. */
|
|
9
|
+
readonly highlightColor?: string;
|
|
10
|
+
/** Hex color drawn over each selected target. */
|
|
11
|
+
readonly selectedColor?: string;
|
|
12
|
+
}
|
|
13
|
+
interface CaptureInstallation {
|
|
14
|
+
readonly dispose: () => void;
|
|
15
|
+
readonly close: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
export { InstallCaptureOptions as n, CaptureInstallation as t };
|
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import { t as readRegisteredCaptureSinks } from "./sink-registry-DAlYVk2U.mjs";
|
|
2
|
+
import { CaptureHostFailureReporter, CaptureMetadataEnrichment, CaptureMode, CaptureModeIntent, CapturePersistence, CapturePersistenceError, CapturedTarget, decodeAnscribeDataEffect, formatCaptureForClipboard, generateCapturedTargetId, isAnscribeOverlay, markAsOverlay, noCaptureEnrichment, resolveTargetAtCell, selectCurrentTarget } from "@anscribe/core";
|
|
3
|
+
import { Effect, Fiber, Layer, ManagedRuntime } from "effect";
|
|
4
|
+
import { BoxRenderable, InputRenderable, InputRenderableEvents, RGBA, TextRenderable } from "@opentui/core";
|
|
5
|
+
import { customAlphabet } from "nanoid";
|
|
6
|
+
//#region src/renderable-tree.ts
|
|
7
|
+
function asRenderableRecord(value) {
|
|
8
|
+
return value !== null && typeof value === "object" ? value : void 0;
|
|
9
|
+
}
|
|
10
|
+
function readRenderableChildren(renderable) {
|
|
11
|
+
const opentuiChildren = typeof renderable.getChildren === "function" ? renderable.getChildren() : void 0;
|
|
12
|
+
return Array.isArray(opentuiChildren) ? opentuiChildren : [];
|
|
13
|
+
}
|
|
14
|
+
function walkRenderableTree(rootRenderable, visit, options = {}) {
|
|
15
|
+
const step = (value, ancestry) => {
|
|
16
|
+
const renderable = asRenderableRecord(value);
|
|
17
|
+
if (renderable === void 0) return;
|
|
18
|
+
if (options.shouldSkipSubtree?.(renderable) === true) return;
|
|
19
|
+
const nextAncestry = [...ancestry, readRenderableType(renderable)];
|
|
20
|
+
visit(renderable, nextAncestry);
|
|
21
|
+
for (const child of readRenderableChildren(renderable)) step(child, nextAncestry);
|
|
22
|
+
};
|
|
23
|
+
step(rootRenderable, []);
|
|
24
|
+
}
|
|
25
|
+
function isRenderableVisible(renderable) {
|
|
26
|
+
return renderable.visible !== false;
|
|
27
|
+
}
|
|
28
|
+
function readRenderableType(renderable) {
|
|
29
|
+
return readConstructorName(renderable.constructor) ?? "Renderable";
|
|
30
|
+
}
|
|
31
|
+
function readFiniteNumber(value) {
|
|
32
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
33
|
+
}
|
|
34
|
+
function readConstructorName(value) {
|
|
35
|
+
if (typeof value === "function") return value.name.length > 0 ? value.name : void 0;
|
|
36
|
+
const name = asRenderableRecord(value)?.name;
|
|
37
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/discovery.ts
|
|
41
|
+
const discoverVisibleTargets = Effect.fn("Discovery.discoverVisibleTargets")(function* (rootRenderable, options) {
|
|
42
|
+
const candidates = [];
|
|
43
|
+
walkRenderableTree(rootRenderable, (renderable, ancestry) => {
|
|
44
|
+
if (!isRenderableVisible(renderable)) return;
|
|
45
|
+
candidates.push({
|
|
46
|
+
renderable,
|
|
47
|
+
ancestry
|
|
48
|
+
});
|
|
49
|
+
}, { shouldSkipSubtree: isAnscribeOverlay });
|
|
50
|
+
return yield* Effect.forEach(candidates, ({ renderable, ancestry }) => Effect.gen(function* () {
|
|
51
|
+
const visibleContent = readVisibleContent(renderable);
|
|
52
|
+
const target = yield* decodeAnscribeDataEffect(CapturedTarget, {
|
|
53
|
+
id: yield* generateCapturedTargetId,
|
|
54
|
+
type: readRenderableType(renderable),
|
|
55
|
+
bounds: readBounds(renderable),
|
|
56
|
+
ancestry,
|
|
57
|
+
...visibleContent != null && { visibleContent }
|
|
58
|
+
});
|
|
59
|
+
const enrichment = yield* readEnrichment(options, renderable, target);
|
|
60
|
+
return enrichment === void 0 ? target : yield* decodeAnscribeDataEffect(CapturedTarget, {
|
|
61
|
+
...target,
|
|
62
|
+
...enrichment.metadata !== void 0 && { metadata: enrichment.metadata },
|
|
63
|
+
...enrichment.sourceReferences !== void 0 && { sourceReferences: enrichment.sourceReferences }
|
|
64
|
+
});
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
67
|
+
function readBounds(renderable) {
|
|
68
|
+
return {
|
|
69
|
+
x: readFiniteNumber(renderable.screenX),
|
|
70
|
+
y: readFiniteNumber(renderable.screenY),
|
|
71
|
+
width: readFiniteNumber(renderable.width),
|
|
72
|
+
height: readFiniteNumber(renderable.height)
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function readVisibleContent(renderable) {
|
|
76
|
+
const text = stringifyVisibleContent(renderable.plainText ?? renderable.content ?? renderable.title);
|
|
77
|
+
return text.length > 0 ? text : void 0;
|
|
78
|
+
}
|
|
79
|
+
function stringifyVisibleContent(content) {
|
|
80
|
+
if (typeof content === "string") return content;
|
|
81
|
+
const chunks = asRenderableRecord(content)?.chunks;
|
|
82
|
+
if (Array.isArray(chunks)) return chunks.map((chunk) => asRenderableRecord(chunk)?.text).filter((text) => typeof text === "string").join("");
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
function readEnrichment(options, renderable, target) {
|
|
86
|
+
return Effect.gen(function* () {
|
|
87
|
+
const enriched = yield* options.metadataEnricher?.({
|
|
88
|
+
renderable,
|
|
89
|
+
target
|
|
90
|
+
}) ?? noCaptureEnrichment;
|
|
91
|
+
const identifier = readRenderableIdentifier(renderable);
|
|
92
|
+
const metadata = enriched?.metadata === void 0 && identifier === void 0 ? void 0 : {
|
|
93
|
+
...enriched?.metadata,
|
|
94
|
+
...identifier !== void 0 ? { identifier } : {}
|
|
95
|
+
};
|
|
96
|
+
const sourceReferences = enriched?.sourceReferences;
|
|
97
|
+
const hasSources = sourceReferences !== void 0 && sourceReferences.length > 0;
|
|
98
|
+
if (metadata === void 0 && !hasSources) return;
|
|
99
|
+
return {
|
|
100
|
+
...metadata !== void 0 && { metadata },
|
|
101
|
+
...hasSources && { sourceReferences }
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function readRenderableIdentifier(renderable) {
|
|
106
|
+
const identifier = renderable.id;
|
|
107
|
+
return typeof identifier === "string" && identifier.length > 0 ? identifier : void 0;
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/internal/ids.ts
|
|
111
|
+
const generateRenderableIdSuffix = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12);
|
|
112
|
+
//#endregion
|
|
113
|
+
//#region src/host-helpers.ts
|
|
114
|
+
const CAPTURE_MOUSE_HANDLED = Symbol("anscribe.captureMouseHandled");
|
|
115
|
+
function readSelectedTargets(state) {
|
|
116
|
+
return state.selectedTargetIds.map((targetId) => state.targets.find((target) => target.id === targetId)).filter((target) => target !== void 0);
|
|
117
|
+
}
|
|
118
|
+
function subscribeToKeypress(renderer, listener) {
|
|
119
|
+
const keyInput = renderer.keyInput;
|
|
120
|
+
if (keyInput === void 0) return;
|
|
121
|
+
if (typeof keyInput.prependListener === "function") keyInput.prependListener("keypress", listener);
|
|
122
|
+
else if (typeof keyInput.on === "function") keyInput.on("keypress", listener);
|
|
123
|
+
else return;
|
|
124
|
+
return () => {
|
|
125
|
+
if (typeof keyInput.off === "function") {
|
|
126
|
+
keyInput.off("keypress", listener);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (typeof keyInput.removeListener === "function") keyInput.removeListener("keypress", listener);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function readRendererWidth(renderer) {
|
|
133
|
+
const rendererRecord = asRenderableRecord(renderer);
|
|
134
|
+
const rootRecord = asRenderableRecord(renderer.root);
|
|
135
|
+
return readFiniteNumber(rendererRecord?.width ?? rootRecord?.width) || 80;
|
|
136
|
+
}
|
|
137
|
+
function readRendererSize(renderer) {
|
|
138
|
+
const rendererRecord = asRenderableRecord(renderer);
|
|
139
|
+
const rootRecord = asRenderableRecord(renderer.root);
|
|
140
|
+
return {
|
|
141
|
+
width: readFiniteNumber(rendererRecord?.width ?? rootRecord?.width) || 80,
|
|
142
|
+
height: readFiniteNumber(rendererRecord?.height ?? rootRecord?.height) || 24
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function shouldSelectFromMouseEvent(event) {
|
|
146
|
+
return event.type === "down" && event.button === 0 && event[CAPTURE_MOUSE_HANDLED] !== true;
|
|
147
|
+
}
|
|
148
|
+
function discoverMouseRenderables(renderer) {
|
|
149
|
+
const mouseRenderables = [];
|
|
150
|
+
walkRenderableTree(renderer.root, (renderable) => {
|
|
151
|
+
if (isRenderableVisible(renderable) && hasProcessMouseEvent(renderable)) mouseRenderables.push(renderable);
|
|
152
|
+
}, { shouldSkipSubtree: isAnscribeOverlay });
|
|
153
|
+
return mouseRenderables;
|
|
154
|
+
}
|
|
155
|
+
function hasProcessMouseEvent(renderable) {
|
|
156
|
+
return typeof renderable.processMouseEvent === "function";
|
|
157
|
+
}
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/inspector.ts
|
|
160
|
+
const INSPECTOR_BORDER_COLOR = "#ffffff";
|
|
161
|
+
const INSPECTOR_TEXT_COLOR = "#ffffff";
|
|
162
|
+
const INSPECTOR_DIM_COLOR = "#777777";
|
|
163
|
+
const VISIBLE_CONTENT_PREVIEW_LIMIT = 32;
|
|
164
|
+
function formatInspectorLines(target) {
|
|
165
|
+
return [
|
|
166
|
+
target.metadata?.componentName ?? target.type,
|
|
167
|
+
readSubtitle(target),
|
|
168
|
+
formatSourceReference(target.sourceReferences?.[0])
|
|
169
|
+
].filter((line) => line !== void 0);
|
|
170
|
+
}
|
|
171
|
+
function readSubtitle(target) {
|
|
172
|
+
const componentName = target.metadata?.componentName;
|
|
173
|
+
const identifier = target.metadata?.identifier;
|
|
174
|
+
if (identifier !== void 0 && identifier !== componentName) return `#${identifier}`;
|
|
175
|
+
const preview = readVisibleContentPreview(target.visibleContent);
|
|
176
|
+
if (preview !== void 0) return preview;
|
|
177
|
+
return componentName !== void 0 ? target.type : void 0;
|
|
178
|
+
}
|
|
179
|
+
function readVisibleContentPreview(content) {
|
|
180
|
+
if (content === void 0) return void 0;
|
|
181
|
+
const collapsed = content.replace(/\s+/g, " ").trim();
|
|
182
|
+
if (collapsed.length === 0) return void 0;
|
|
183
|
+
return `"${collapsed.length > VISIBLE_CONTENT_PREVIEW_LIMIT ? `${collapsed.slice(0, VISIBLE_CONTENT_PREVIEW_LIMIT - 1)}…` : collapsed}"`;
|
|
184
|
+
}
|
|
185
|
+
function formatSourceReference(reference) {
|
|
186
|
+
if (reference === void 0) return void 0;
|
|
187
|
+
const { file, line } = reference;
|
|
188
|
+
if (file === void 0) return void 0;
|
|
189
|
+
const basename = file.slice(file.lastIndexOf("/") + 1);
|
|
190
|
+
return line === void 0 ? basename : `${basename}:${line}`;
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
//#region src/host-core.ts
|
|
194
|
+
const DEFAULT_HIGHLIGHT_HEX = "#06b6d4";
|
|
195
|
+
const DEFAULT_SELECTED_HEX = "#ec4899";
|
|
196
|
+
const HIGHLIGHT_ALPHA = 100;
|
|
197
|
+
const SELECTED_ALPHA = 130;
|
|
198
|
+
const HOVER_PLACEHOLDER_MESSAGE = "Move mouse over a target";
|
|
199
|
+
const INSPECTOR_BACKGROUND_RGBA = RGBA.fromValues(0, 0, 0, .6);
|
|
200
|
+
const INSPECTOR_ID_PREFIX = "anscribe-inspector-";
|
|
201
|
+
const SELECTED_OVERLAY_Z_INDEX = 999998;
|
|
202
|
+
const HIGHLIGHT_OVERLAY_Z_INDEX = 999999;
|
|
203
|
+
const HIGHLIGHT_OVERLAY_ID_PREFIX = "anscribe-highlight-";
|
|
204
|
+
const SELECTED_OVERLAY_ID_PREFIX = "anscribe-selected-";
|
|
205
|
+
function installPickerHostCore(renderer, initialState, options) {
|
|
206
|
+
const highlightRGBA = hexToOverlayRGBA(options.highlightColor ?? DEFAULT_HIGHLIGHT_HEX, HIGHLIGHT_ALPHA);
|
|
207
|
+
const selectedRGBA = hexToOverlayRGBA(options.selectedColor ?? DEFAULT_SELECTED_HEX, SELECTED_ALPHA);
|
|
208
|
+
let lastSyncedState = initialState;
|
|
209
|
+
let hoveredTargetId;
|
|
210
|
+
const requestRender = () => {
|
|
211
|
+
renderer.requestRender?.();
|
|
212
|
+
};
|
|
213
|
+
let highlightOverlay;
|
|
214
|
+
const selectedOverlays = /* @__PURE__ */ new Map();
|
|
215
|
+
const makeOverlay = (target, background, zIndex, idPrefix) => {
|
|
216
|
+
const overlay = new BoxRenderable(renderer, {
|
|
217
|
+
id: `${idPrefix}${generateRenderableIdSuffix()}`,
|
|
218
|
+
position: "absolute",
|
|
219
|
+
left: target.bounds.x,
|
|
220
|
+
top: target.bounds.y,
|
|
221
|
+
width: Math.max(target.bounds.width, 1),
|
|
222
|
+
height: Math.max(target.bounds.height, 1),
|
|
223
|
+
zIndex,
|
|
224
|
+
backgroundColor: background,
|
|
225
|
+
shouldFill: true
|
|
226
|
+
});
|
|
227
|
+
markAsOverlay(overlay);
|
|
228
|
+
renderer.root.add(overlay);
|
|
229
|
+
return overlay;
|
|
230
|
+
};
|
|
231
|
+
const applyOverlayBounds = (overlay, bounds) => {
|
|
232
|
+
overlay.left = bounds.x;
|
|
233
|
+
overlay.top = bounds.y;
|
|
234
|
+
overlay.width = Math.max(bounds.width, 1);
|
|
235
|
+
overlay.height = Math.max(bounds.height, 1);
|
|
236
|
+
};
|
|
237
|
+
const destroyHighlightOverlay = () => {
|
|
238
|
+
if (highlightOverlay === void 0) return;
|
|
239
|
+
highlightOverlay.destroyRecursively();
|
|
240
|
+
highlightOverlay = void 0;
|
|
241
|
+
};
|
|
242
|
+
const destroyAllSelectedOverlays = () => {
|
|
243
|
+
for (const overlay of selectedOverlays.values()) overlay.destroyRecursively();
|
|
244
|
+
selectedOverlays.clear();
|
|
245
|
+
};
|
|
246
|
+
const syncHighlightOverlay = (state) => {
|
|
247
|
+
const highlighted = selectCurrentTarget(state);
|
|
248
|
+
const selectedTargets = state.active ? readSelectedTargets(state) : [];
|
|
249
|
+
const wantSelectedIds = new Set(selectedTargets.map((target) => target.id));
|
|
250
|
+
for (const [id, overlay] of selectedOverlays) {
|
|
251
|
+
if (wantSelectedIds.has(id)) continue;
|
|
252
|
+
overlay.destroyRecursively();
|
|
253
|
+
selectedOverlays.delete(id);
|
|
254
|
+
}
|
|
255
|
+
for (const target of selectedTargets) {
|
|
256
|
+
const existing = selectedOverlays.get(target.id);
|
|
257
|
+
if (existing !== void 0) {
|
|
258
|
+
applyOverlayBounds(existing, target.bounds);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
selectedOverlays.set(target.id, makeOverlay(target, selectedRGBA, SELECTED_OVERLAY_Z_INDEX, SELECTED_OVERLAY_ID_PREFIX));
|
|
262
|
+
}
|
|
263
|
+
if (highlighted === void 0) destroyHighlightOverlay();
|
|
264
|
+
else if (highlightOverlay === void 0) highlightOverlay = makeOverlay(highlighted, highlightRGBA, HIGHLIGHT_OVERLAY_Z_INDEX, HIGHLIGHT_OVERLAY_ID_PREFIX);
|
|
265
|
+
else applyOverlayBounds(highlightOverlay, highlighted.bounds);
|
|
266
|
+
requestRender();
|
|
267
|
+
};
|
|
268
|
+
let liveInspector;
|
|
269
|
+
let lastSyncedInspectorLines;
|
|
270
|
+
const destroyInspector = () => {
|
|
271
|
+
if (liveInspector === void 0) return;
|
|
272
|
+
liveInspector.container.destroyRecursively();
|
|
273
|
+
liveInspector = void 0;
|
|
274
|
+
lastSyncedInspectorLines = void 0;
|
|
275
|
+
};
|
|
276
|
+
const ensureInspector = () => {
|
|
277
|
+
if (liveInspector !== void 0) return liveInspector;
|
|
278
|
+
const container = new BoxRenderable(renderer, {
|
|
279
|
+
id: `${INSPECTOR_ID_PREFIX}${generateRenderableIdSuffix()}`,
|
|
280
|
+
position: "absolute",
|
|
281
|
+
right: 1,
|
|
282
|
+
top: 1,
|
|
283
|
+
width: 36,
|
|
284
|
+
height: 3,
|
|
285
|
+
zIndex: 1e6,
|
|
286
|
+
border: true,
|
|
287
|
+
borderStyle: "single",
|
|
288
|
+
borderColor: INSPECTOR_BORDER_COLOR,
|
|
289
|
+
backgroundColor: INSPECTOR_BACKGROUND_RGBA,
|
|
290
|
+
shouldFill: true,
|
|
291
|
+
title: "inspector",
|
|
292
|
+
paddingX: 1
|
|
293
|
+
});
|
|
294
|
+
const text = new TextRenderable(renderer, {
|
|
295
|
+
id: `${container.id}-text`,
|
|
296
|
+
content: "",
|
|
297
|
+
fg: INSPECTOR_TEXT_COLOR
|
|
298
|
+
});
|
|
299
|
+
markAsOverlay(container);
|
|
300
|
+
markAsOverlay(text);
|
|
301
|
+
container.add(text);
|
|
302
|
+
renderer.root.add(container);
|
|
303
|
+
liveInspector = {
|
|
304
|
+
container,
|
|
305
|
+
text
|
|
306
|
+
};
|
|
307
|
+
return liveInspector;
|
|
308
|
+
};
|
|
309
|
+
const syncInspector = (state) => {
|
|
310
|
+
const content = computeInspectorContent(state, hoveredTargetId);
|
|
311
|
+
if (content === void 0) {
|
|
312
|
+
destroyInspector();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const lines = content.lines;
|
|
316
|
+
const inspector = ensureInspector();
|
|
317
|
+
inspector.container.height = Math.max(lines.length + 2, 3);
|
|
318
|
+
if (lastSyncedInspectorLines === void 0 || lastSyncedInspectorLines.length !== lines.length || lastSyncedInspectorLines.some((line, index) => line !== lines[index])) {
|
|
319
|
+
inspector.text.content = lines.join("\n");
|
|
320
|
+
inspector.text.fg = content.kind === "placeholder" ? INSPECTOR_DIM_COLOR : INSPECTOR_TEXT_COLOR;
|
|
321
|
+
lastSyncedInspectorLines = lines;
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const updateHoverFromEvent = (event) => {
|
|
325
|
+
if (!lastSyncedState.active) return;
|
|
326
|
+
const nextHoveredId = resolveTargetAtCell(lastSyncedState.targets, event.x, event.y)?.id;
|
|
327
|
+
if (nextHoveredId === hoveredTargetId) return;
|
|
328
|
+
hoveredTargetId = nextHoveredId;
|
|
329
|
+
syncInspector(lastSyncedState);
|
|
330
|
+
requestRender();
|
|
331
|
+
};
|
|
332
|
+
const mousePatches = /* @__PURE__ */ new Map();
|
|
333
|
+
const restoreMouseHandlers = () => {
|
|
334
|
+
for (const [renderable, processMouseEvent] of mousePatches) renderable.processMouseEvent = processMouseEvent;
|
|
335
|
+
mousePatches.clear();
|
|
336
|
+
};
|
|
337
|
+
const patchMouseHandlers = () => {
|
|
338
|
+
for (const renderable of discoverMouseRenderables(renderer)) {
|
|
339
|
+
if (mousePatches.has(renderable)) continue;
|
|
340
|
+
const originalProcessMouseEvent = renderable.processMouseEvent.bind(renderable);
|
|
341
|
+
mousePatches.set(renderable, originalProcessMouseEvent);
|
|
342
|
+
renderable.processMouseEvent = (event) => {
|
|
343
|
+
if (lastSyncedState.active) {
|
|
344
|
+
if (event.type === "move" || event.type === "drag") updateHoverFromEvent(event);
|
|
345
|
+
else if (shouldSelectFromMouseEvent(event)) {
|
|
346
|
+
event[CAPTURE_MOUSE_HANDLED] = true;
|
|
347
|
+
options.onPrimaryClick(event);
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
event.stopPropagation();
|
|
350
|
+
requestRender();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
originalProcessMouseEvent(event);
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
const clearHover = () => {
|
|
359
|
+
if (hoveredTargetId === void 0) return;
|
|
360
|
+
hoveredTargetId = void 0;
|
|
361
|
+
};
|
|
362
|
+
const syncRenderables = (next) => {
|
|
363
|
+
const previous = lastSyncedState;
|
|
364
|
+
lastSyncedState = next;
|
|
365
|
+
if (!previous.active && next.active) patchMouseHandlers();
|
|
366
|
+
else if (previous.active && !next.active) {
|
|
367
|
+
restoreMouseHandlers();
|
|
368
|
+
clearHover();
|
|
369
|
+
}
|
|
370
|
+
if (hoveredTargetId !== void 0 && !next.targets.some((target) => target.id === hoveredTargetId)) clearHover();
|
|
371
|
+
syncHighlightOverlay(next);
|
|
372
|
+
syncInspector(next);
|
|
373
|
+
};
|
|
374
|
+
const removeKeypressListener = subscribeToKeypress(renderer, options.onKeypress);
|
|
375
|
+
syncRenderables(initialState);
|
|
376
|
+
return {
|
|
377
|
+
syncRenderables,
|
|
378
|
+
requestRender,
|
|
379
|
+
dispose: () => {
|
|
380
|
+
removeKeypressListener?.();
|
|
381
|
+
destroyInspector();
|
|
382
|
+
destroyHighlightOverlay();
|
|
383
|
+
destroyAllSelectedOverlays();
|
|
384
|
+
restoreMouseHandlers();
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function computeInspectorContent(state, hoveredTargetId) {
|
|
389
|
+
if (!state.active) return void 0;
|
|
390
|
+
const hovered = hoveredTargetId !== void 0 ? state.targets.find((target) => target.id === hoveredTargetId) : void 0;
|
|
391
|
+
if (hovered !== void 0) return {
|
|
392
|
+
kind: "target",
|
|
393
|
+
lines: formatInspectorLines(hovered)
|
|
394
|
+
};
|
|
395
|
+
const firstSelected = readSelectedTargets(state)[0];
|
|
396
|
+
if (firstSelected !== void 0) return {
|
|
397
|
+
kind: "target",
|
|
398
|
+
lines: formatInspectorLines(firstSelected)
|
|
399
|
+
};
|
|
400
|
+
const current = selectCurrentTarget(state);
|
|
401
|
+
if (current !== void 0) return {
|
|
402
|
+
kind: "target",
|
|
403
|
+
lines: formatInspectorLines(current)
|
|
404
|
+
};
|
|
405
|
+
if (state.targets.length > 0) return {
|
|
406
|
+
kind: "placeholder",
|
|
407
|
+
lines: [HOVER_PLACEHOLDER_MESSAGE]
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function hexToOverlayRGBA(hex, alpha0to255) {
|
|
411
|
+
const cleaned = hex.replace(/^#/, "");
|
|
412
|
+
const r = parseInt(cleaned.slice(0, 2), 16);
|
|
413
|
+
const g = parseInt(cleaned.slice(2, 4), 16);
|
|
414
|
+
const b = parseInt(cleaned.slice(4, 6), 16);
|
|
415
|
+
return RGBA.fromInts(r, g, b, alpha0to255);
|
|
416
|
+
}
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/keys.ts
|
|
419
|
+
function routeCaptureKey(input) {
|
|
420
|
+
const { state, key } = input;
|
|
421
|
+
if (state.instructionDraft) return key === "escape" || key === "esc" ? {
|
|
422
|
+
_tag: "Intent",
|
|
423
|
+
intent: CaptureModeIntent.CancelDraft()
|
|
424
|
+
} : void 0;
|
|
425
|
+
if (!state.active && key === normalizeKey(input.keybinding ?? "ctrl+g")) return { _tag: "EnterMode" };
|
|
426
|
+
if (!state.active) return;
|
|
427
|
+
const intent = mapActiveKey(key);
|
|
428
|
+
return intent === void 0 ? void 0 : {
|
|
429
|
+
_tag: "Intent",
|
|
430
|
+
intent
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const EXIT_KEYS = new Set([
|
|
434
|
+
"escape",
|
|
435
|
+
"esc",
|
|
436
|
+
"q"
|
|
437
|
+
]);
|
|
438
|
+
const NEXT_KEYS = new Set([
|
|
439
|
+
"tab",
|
|
440
|
+
"arrowdown",
|
|
441
|
+
"arrowright",
|
|
442
|
+
"down",
|
|
443
|
+
"right",
|
|
444
|
+
"j"
|
|
445
|
+
]);
|
|
446
|
+
const PREVIOUS_KEYS = new Set([
|
|
447
|
+
"shift+tab",
|
|
448
|
+
"arrowup",
|
|
449
|
+
"arrowleft",
|
|
450
|
+
"up",
|
|
451
|
+
"left",
|
|
452
|
+
"k"
|
|
453
|
+
]);
|
|
454
|
+
const TOGGLE_KEYS = new Set([
|
|
455
|
+
" ",
|
|
456
|
+
"space",
|
|
457
|
+
"enter",
|
|
458
|
+
"return"
|
|
459
|
+
]);
|
|
460
|
+
const DESELECT_KEYS = new Set(["backspace", "delete"]);
|
|
461
|
+
function mapActiveKey(key) {
|
|
462
|
+
if (EXIT_KEYS.has(key)) return CaptureModeIntent.ExitMode();
|
|
463
|
+
if (key === "a") return CaptureModeIntent.StartDraft();
|
|
464
|
+
if (NEXT_KEYS.has(key)) return CaptureModeIntent.MoveSelection({ direction: "next" });
|
|
465
|
+
if (PREVIOUS_KEYS.has(key)) return CaptureModeIntent.MoveSelection({ direction: "previous" });
|
|
466
|
+
if (TOGGLE_KEYS.has(key)) return CaptureModeIntent.ToggleCurrent();
|
|
467
|
+
if (DESELECT_KEYS.has(key)) return CaptureModeIntent.DeselectCurrent();
|
|
468
|
+
}
|
|
469
|
+
function readKeyEventInput(event) {
|
|
470
|
+
const parts = [];
|
|
471
|
+
if (event.ctrl) parts.push("ctrl");
|
|
472
|
+
if (event.meta || event.option) parts.push("meta");
|
|
473
|
+
if (event.shift) parts.push("shift");
|
|
474
|
+
parts.push(event.name === "linefeed" ? "enter" : event.name);
|
|
475
|
+
return normalizeKey(parts.join("+"));
|
|
476
|
+
}
|
|
477
|
+
function normalizeKey(input) {
|
|
478
|
+
return input.trim().toLowerCase().replaceAll(" ", "");
|
|
479
|
+
}
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/capture-host.ts
|
|
482
|
+
const INSTRUCTION_BORDER_COLOR = "#ffffff";
|
|
483
|
+
const INSTRUCTION_TEXT_COLOR = "#ffffff";
|
|
484
|
+
const INSTRUCTION_BACKGROUND_COLOR = "#000000";
|
|
485
|
+
const INSTRUCTION_HEIGHT = 3;
|
|
486
|
+
function makeCaptureHostLayer(renderer, options = {}) {
|
|
487
|
+
return Layer.effectDiscard(buildCaptureHost(renderer, options));
|
|
488
|
+
}
|
|
489
|
+
const buildCaptureHost = (renderer, options) => Effect.gen(function* () {
|
|
490
|
+
const captureMode = yield* CaptureMode;
|
|
491
|
+
const capturePersistence = yield* CapturePersistence;
|
|
492
|
+
const enrichment = yield* CaptureMetadataEnrichment;
|
|
493
|
+
const failureReporter = yield* CaptureHostFailureReporter;
|
|
494
|
+
const captureModeContext = yield* Effect.context();
|
|
495
|
+
const pendingPersistence = /* @__PURE__ */ new Set();
|
|
496
|
+
const discoveryOptions = { metadataEnricher: (input) => enrichment.enrich(input) };
|
|
497
|
+
const initialState = yield* captureMode.current();
|
|
498
|
+
let lastSyncedState = initialState;
|
|
499
|
+
let liveInstructionDraft;
|
|
500
|
+
const commitDraftFromInput = (body) => {
|
|
501
|
+
dispatch(CaptureModeIntent.CommitDraft({ body }));
|
|
502
|
+
};
|
|
503
|
+
const syncInstructionDraftRenderable = (state) => {
|
|
504
|
+
if (!state.instructionDraft) {
|
|
505
|
+
if (liveInstructionDraft !== void 0) {
|
|
506
|
+
destroyInstructionDraft(liveInstructionDraft);
|
|
507
|
+
liveInstructionDraft = void 0;
|
|
508
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (liveInstructionDraft === void 0) {
|
|
512
|
+
const target = readAnchorTarget(state);
|
|
513
|
+
if (target === void 0) return;
|
|
514
|
+
liveInstructionDraft = createInstructionDraft(renderer, commitDraftFromInput, target.bounds);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const dispatch = (intent) => {
|
|
518
|
+
const previous = lastSyncedState;
|
|
519
|
+
const result = Effect.runSyncWith(captureModeContext)(captureMode.dispatch(intent).pipe(Effect.catch((error) => Effect.as(failureReporter.report(error), { state: previous }))));
|
|
520
|
+
const next = result.state;
|
|
521
|
+
lastSyncedState = next;
|
|
522
|
+
hostCore.syncRenderables(next);
|
|
523
|
+
syncInstructionDraftRenderable(next);
|
|
524
|
+
if (result.toPersist !== void 0) {
|
|
525
|
+
const fiber = Effect.runForkWith(captureModeContext)(capturePersistence.createCapture(result.toPersist).pipe(Effect.catch((error) => failureReporter.report(error))));
|
|
526
|
+
fiber.addObserver(() => {
|
|
527
|
+
pendingPersistence.delete(fiber);
|
|
528
|
+
});
|
|
529
|
+
pendingPersistence.add(fiber);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const handleKeypress = (event) => {
|
|
533
|
+
const route = routeCaptureKey({
|
|
534
|
+
state: lastSyncedState,
|
|
535
|
+
key: readKeyEventInput(event),
|
|
536
|
+
keybinding: options.keybinding
|
|
537
|
+
});
|
|
538
|
+
if (route === void 0) return;
|
|
539
|
+
if (route._tag === "EnterMode") {
|
|
540
|
+
const targets = Effect.runSyncWith(captureModeContext)(discoverVisibleTargets(renderer.root, discoveryOptions).pipe(Effect.catch((error) => Effect.as(failureReporter.report(error), []))));
|
|
541
|
+
dispatch(CaptureModeIntent.EnterMode({ targets }));
|
|
542
|
+
} else dispatch(route.intent);
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
event.stopPropagation();
|
|
545
|
+
hostCore.requestRender();
|
|
546
|
+
};
|
|
547
|
+
const hostCore = installPickerHostCore(renderer, initialState, {
|
|
548
|
+
onPrimaryClick: (event) => {
|
|
549
|
+
if (lastSyncedState.instructionDraft) {
|
|
550
|
+
dispatch(CaptureModeIntent.CancelDraft());
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
dispatch(CaptureModeIntent.SelectAtCell({
|
|
554
|
+
x: event.x,
|
|
555
|
+
y: event.y
|
|
556
|
+
}));
|
|
557
|
+
},
|
|
558
|
+
onKeypress: handleKeypress,
|
|
559
|
+
highlightColor: options.highlightColor,
|
|
560
|
+
selectedColor: options.selectedColor
|
|
561
|
+
});
|
|
562
|
+
const handleResize = () => {
|
|
563
|
+
if (liveInstructionDraft === void 0) return;
|
|
564
|
+
resizeInstructionDraft(liveInstructionDraft, renderer);
|
|
565
|
+
hostCore.requestRender();
|
|
566
|
+
};
|
|
567
|
+
renderer.on("resize", handleResize);
|
|
568
|
+
yield* Effect.addFinalizer(() => Effect.gen(function* () {
|
|
569
|
+
dispatch(CaptureModeIntent.ExitMode());
|
|
570
|
+
renderer.off("resize", handleResize);
|
|
571
|
+
if (liveInstructionDraft !== void 0) {
|
|
572
|
+
destroyInstructionDraft(liveInstructionDraft);
|
|
573
|
+
liveInstructionDraft = void 0;
|
|
574
|
+
}
|
|
575
|
+
hostCore.dispose();
|
|
576
|
+
yield* Fiber.awaitAll(pendingPersistence).pipe(Effect.asVoid);
|
|
577
|
+
}));
|
|
578
|
+
});
|
|
579
|
+
function computeDraftWidths(renderer) {
|
|
580
|
+
const boxWidth = Math.max(Math.min(readRendererWidth(renderer), 44), 18);
|
|
581
|
+
return {
|
|
582
|
+
boxWidth,
|
|
583
|
+
inputWidth: Math.max(boxWidth - 4, 1)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
const PLACEMENT_PADDING = 1;
|
|
587
|
+
function computeDraftPlacement(viewport, width, targetBounds) {
|
|
588
|
+
const candidates = [
|
|
589
|
+
{
|
|
590
|
+
left: targetBounds.x - width - PLACEMENT_PADDING,
|
|
591
|
+
top: targetBounds.y
|
|
592
|
+
},
|
|
593
|
+
{
|
|
594
|
+
left: targetBounds.x + targetBounds.width + PLACEMENT_PADDING,
|
|
595
|
+
top: targetBounds.y
|
|
596
|
+
},
|
|
597
|
+
{
|
|
598
|
+
left: targetBounds.x,
|
|
599
|
+
top: targetBounds.y + targetBounds.height + PLACEMENT_PADDING
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
left: targetBounds.x,
|
|
603
|
+
top: targetBounds.y - INSTRUCTION_HEIGHT - PLACEMENT_PADDING
|
|
604
|
+
}
|
|
605
|
+
];
|
|
606
|
+
for (const candidate of candidates) if (candidate.left >= 0 && candidate.top >= 0 && candidate.left + width <= viewport.width && candidate.top + INSTRUCTION_HEIGHT <= viewport.height) return candidate;
|
|
607
|
+
const fallback = candidates[0];
|
|
608
|
+
return {
|
|
609
|
+
left: Math.max(0, Math.min(fallback.left, Math.max(viewport.width - width, 0))),
|
|
610
|
+
top: Math.max(0, Math.min(fallback.top, Math.max(viewport.height - INSTRUCTION_HEIGHT, 0)))
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function readAnchorTarget(state) {
|
|
614
|
+
return readSelectedTargets(state)[0] ?? selectCurrentTarget(state);
|
|
615
|
+
}
|
|
616
|
+
function createInstructionDraft(renderer, onSubmit, targetBounds) {
|
|
617
|
+
const { boxWidth, inputWidth } = computeDraftWidths(renderer);
|
|
618
|
+
const placement = computeDraftPlacement(readRendererSize(renderer), boxWidth, targetBounds);
|
|
619
|
+
const container = new BoxRenderable(renderer, {
|
|
620
|
+
id: `anscribe-capture-instruction-${generateRenderableIdSuffix()}`,
|
|
621
|
+
position: "absolute",
|
|
622
|
+
left: placement.left,
|
|
623
|
+
top: placement.top,
|
|
624
|
+
width: boxWidth,
|
|
625
|
+
height: INSTRUCTION_HEIGHT,
|
|
626
|
+
zIndex: 1e4,
|
|
627
|
+
border: true,
|
|
628
|
+
borderStyle: "single",
|
|
629
|
+
borderColor: INSTRUCTION_BORDER_COLOR,
|
|
630
|
+
backgroundColor: INSTRUCTION_BACKGROUND_COLOR,
|
|
631
|
+
shouldFill: true,
|
|
632
|
+
title: "capture instruction",
|
|
633
|
+
paddingX: 1
|
|
634
|
+
});
|
|
635
|
+
const input = new InputRenderable(renderer, {
|
|
636
|
+
id: `${container.id}-input`,
|
|
637
|
+
width: inputWidth,
|
|
638
|
+
value: "",
|
|
639
|
+
placeholder: "enter instruction",
|
|
640
|
+
backgroundColor: INSTRUCTION_BACKGROUND_COLOR,
|
|
641
|
+
focusedBackgroundColor: INSTRUCTION_BACKGROUND_COLOR,
|
|
642
|
+
textColor: INSTRUCTION_TEXT_COLOR,
|
|
643
|
+
focusedTextColor: INSTRUCTION_TEXT_COLOR,
|
|
644
|
+
placeholderColor: "#777777",
|
|
645
|
+
cursorColor: INSTRUCTION_BORDER_COLOR
|
|
646
|
+
});
|
|
647
|
+
input.on(InputRenderableEvents.ENTER, onSubmit);
|
|
648
|
+
markAsOverlay(container);
|
|
649
|
+
markAsOverlay(input);
|
|
650
|
+
container.add(input);
|
|
651
|
+
renderer.root.add(container);
|
|
652
|
+
input.focus();
|
|
653
|
+
return {
|
|
654
|
+
container,
|
|
655
|
+
input,
|
|
656
|
+
targetBounds
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
function resizeInstructionDraft(draft, renderer) {
|
|
660
|
+
const { boxWidth, inputWidth } = computeDraftWidths(renderer);
|
|
661
|
+
const placement = computeDraftPlacement(readRendererSize(renderer), boxWidth, draft.targetBounds);
|
|
662
|
+
if (draft.container !== void 0) {
|
|
663
|
+
draft.container.width = boxWidth;
|
|
664
|
+
draft.container.left = placement.left;
|
|
665
|
+
draft.container.top = placement.top;
|
|
666
|
+
}
|
|
667
|
+
if (draft.input !== void 0) draft.input.width = inputWidth;
|
|
668
|
+
}
|
|
669
|
+
function destroyInstructionDraft(draft) {
|
|
670
|
+
if (draft === void 0) return;
|
|
671
|
+
if (draft.input !== void 0) {
|
|
672
|
+
draft.input.blur();
|
|
673
|
+
draft.input = void 0;
|
|
674
|
+
}
|
|
675
|
+
draft.container?.destroyRecursively();
|
|
676
|
+
draft.container = void 0;
|
|
677
|
+
}
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/internal/install.ts
|
|
680
|
+
function installCaptureWithEnrichment(renderer, options, enricherLayer) {
|
|
681
|
+
const sinks = readRegisteredCaptureSinks();
|
|
682
|
+
const composedLayer = makeCaptureHostLayer(renderer, options).pipe(Layer.provideMerge(Layer.mergeAll(CaptureMode.live, makeCompositePersistenceLayer(renderer, sinks), enricherLayer, CaptureHostFailureReporter.live)));
|
|
683
|
+
const runtime = ManagedRuntime.make(composedLayer);
|
|
684
|
+
runtime.runSync(Effect.void);
|
|
685
|
+
return {
|
|
686
|
+
close: () => runtime.dispose(),
|
|
687
|
+
dispose() {
|
|
688
|
+
runtime.dispose().catch(() => void 0);
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
const makeCompositePersistenceLayer = (renderer, sinks) => Layer.effect(CapturePersistence, Effect.gen(function* () {
|
|
693
|
+
for (const sink of sinks) {
|
|
694
|
+
const cleanup = sink.close;
|
|
695
|
+
if (cleanup !== void 0) yield* Effect.addFinalizer(() => Effect.promise(() => cleanup()).pipe(Effect.orDie));
|
|
696
|
+
}
|
|
697
|
+
return CapturePersistence.of({ createCapture: (capture) => Effect.gen(function* () {
|
|
698
|
+
yield* Effect.sync(() => {
|
|
699
|
+
renderer.copyToClipboardOSC52(formatCaptureForClipboard(capture));
|
|
700
|
+
});
|
|
701
|
+
yield* Effect.forEach(sinks, (sink) => Effect.tryPromise({
|
|
702
|
+
try: () => sink.write(capture),
|
|
703
|
+
catch: (cause) => new CapturePersistenceError({
|
|
704
|
+
message: `Anscribe sink "${sink.name}" failed to persist Capture`,
|
|
705
|
+
cause
|
|
706
|
+
})
|
|
707
|
+
}), { discard: true });
|
|
708
|
+
}) });
|
|
709
|
+
}));
|
|
710
|
+
//#endregion
|
|
711
|
+
export { installCaptureWithEnrichment as t };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
package/dist/react.d.mts
ADDED
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { t as installCaptureWithEnrichment } from "./install-DYrt2HTN.mjs";
|
|
2
|
+
import { CaptureMetadataEnrichment } from "@anscribe/core";
|
|
3
|
+
import { isReactRuntimeEnrichmentAvailable, reactMetadataEnricher } from "@anscribe/react";
|
|
4
|
+
import { useRenderer } from "@opentui/react";
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
//#region src/react/index.ts
|
|
7
|
+
let warnedReactPreloadUnavailable = false;
|
|
8
|
+
const reactEnrichmentLayer = CaptureMetadataEnrichment.layer(reactMetadataEnricher);
|
|
9
|
+
const isProductionEnv = readNodeEnv() === "production";
|
|
10
|
+
function Anscribe(props) {
|
|
11
|
+
const renderer = useRenderer();
|
|
12
|
+
const { keybinding, highlightColor, selectedColor } = props;
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
warnIfReactPreloadUnavailable();
|
|
15
|
+
const capture = installCaptureWithEnrichment(renderer, {
|
|
16
|
+
keybinding,
|
|
17
|
+
highlightColor,
|
|
18
|
+
selectedColor
|
|
19
|
+
}, reactEnrichmentLayer);
|
|
20
|
+
return () => {
|
|
21
|
+
capture.dispose();
|
|
22
|
+
};
|
|
23
|
+
}, [
|
|
24
|
+
keybinding,
|
|
25
|
+
highlightColor,
|
|
26
|
+
selectedColor,
|
|
27
|
+
renderer
|
|
28
|
+
]);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
function warnIfReactPreloadUnavailable() {
|
|
32
|
+
if (warnedReactPreloadUnavailable || isProductionEnv) return;
|
|
33
|
+
if (isReactRuntimeEnrichmentAvailable()) return;
|
|
34
|
+
warnedReactPreloadUnavailable = true;
|
|
35
|
+
const warn = globalThis.console["warn"];
|
|
36
|
+
warn("Anscribe React enrichment is unavailable. Import \"@anscribe/opentui/react/preload\" before \"@opentui/react\" to capture component metadata.");
|
|
37
|
+
}
|
|
38
|
+
function readNodeEnv() {
|
|
39
|
+
return globalThis.process?.env?.NODE_ENV;
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
export { Anscribe };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/internal/sink-registry.ts
|
|
2
|
+
const registeredSinks = /* @__PURE__ */ new Map();
|
|
3
|
+
const registerCaptureSink = (sink) => {
|
|
4
|
+
registeredSinks.set(sink.name, sink);
|
|
5
|
+
};
|
|
6
|
+
const readRegisteredCaptureSinks = () => Array.from(registeredSinks.values());
|
|
7
|
+
/**
|
|
8
|
+
* Test-only escape hatch. Production code should never call this — the
|
|
9
|
+
* registry is process-global so a stray reset between two consumers in the
|
|
10
|
+
* same process would silently break the second one.
|
|
11
|
+
*/
|
|
12
|
+
const resetCaptureSinks = () => {
|
|
13
|
+
registeredSinks.clear();
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { registerCaptureSink as n, resetCaptureSinks as r, readRegisteredCaptureSinks as t };
|
package/dist/sinks.d.mts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CaptureSink, CaptureSink as CaptureSink$1 } from "@anscribe/core";
|
|
2
|
+
|
|
3
|
+
//#region src/internal/sink-registry.d.ts
|
|
4
|
+
declare const registerCaptureSink: (sink: CaptureSink$1) => void;
|
|
5
|
+
declare const readRegisteredCaptureSinks: () => ReadonlyArray<CaptureSink$1>;
|
|
6
|
+
/**
|
|
7
|
+
* Test-only escape hatch. Production code should never call this — the
|
|
8
|
+
* registry is process-global so a stray reset between two consumers in the
|
|
9
|
+
* same process would silently break the second one.
|
|
10
|
+
*/
|
|
11
|
+
declare const resetCaptureSinks: () => void;
|
|
12
|
+
//#endregion
|
|
13
|
+
export { type CaptureSink, readRegisteredCaptureSinks, registerCaptureSink, resetCaptureSinks };
|
package/dist/sinks.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@anscribe/opentui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Anscribe OpenTUI adapter: install capture mode in OpenTUI apps and stream captures to an agent via MCP.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"anscribe",
|
|
8
|
+
"capture",
|
|
9
|
+
"mcp",
|
|
10
|
+
"opentui",
|
|
11
|
+
"tui"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/msmps/anscribe",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/msmps/anscribe/issues"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "msmps",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/msmps/anscribe.git",
|
|
22
|
+
"directory": "packages/opentui"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"exports": {
|
|
29
|
+
"./package.json": "./package.json",
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.mts",
|
|
32
|
+
"import": "./dist/index.mjs"
|
|
33
|
+
},
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./dist/react.d.mts",
|
|
36
|
+
"import": "./dist/react.mjs"
|
|
37
|
+
},
|
|
38
|
+
"./react/preload": {
|
|
39
|
+
"types": "./dist/react/preload.d.mts",
|
|
40
|
+
"import": "./dist/react/preload.mjs"
|
|
41
|
+
},
|
|
42
|
+
"./sinks": {
|
|
43
|
+
"types": "./dist/sinks.d.mts",
|
|
44
|
+
"import": "./dist/sinks.mjs"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public",
|
|
49
|
+
"provenance": true,
|
|
50
|
+
"registry": "https://registry.npmjs.org"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"effect": "^4.0.0-beta.65",
|
|
54
|
+
"nanoid": "^5.1.6",
|
|
55
|
+
"@anscribe/core": "0.1.0",
|
|
56
|
+
"@anscribe/react": "0.1.0"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@opentui/core": "^0.2.8",
|
|
60
|
+
"@opentui/react": "0.2.8",
|
|
61
|
+
"react": ">=19.2.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@opentui/react": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"react": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsdown",
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"test:integration": "bun test test/integration"
|
|
75
|
+
}
|
|
76
|
+
}
|