omkit 0.8.17 → 0.8.19

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.
@@ -1,11 +1,15 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { DiscoveredOm } from "../../../client/registry.ts";
3
3
  /**
4
- * A searchable, arrow-navigable list of oms; Enter selects the highlighted one.
4
+ * The picker: a searchable, arrow-navigable list of oms; Enter runs the highlighted one.
5
5
  *
6
- * Rows are ordered by {@link byNesting} from the working directory — the same root the path
7
- * column is rendered from, so the order matches what the column shows: the oms nearest the
8
- * top of the project first, deeper ones after, and any shown as an absolute path last.
6
+ * Each row is the om's name and its summary (or, without one, its path); the search matches
7
+ * both. Under the list, the highlighted om in full: its summary, where it is defined, and the
8
+ * actions its body calls with their tags — the same outline the skill file shows.
9
+ *
10
+ * Rows are ordered by {@link byNesting} from the working directory — the same root the paths
11
+ * are rendered from, so the oms nearest the top of the project come first, deeper ones after,
12
+ * and any shown as an absolute path last.
9
13
  */
10
14
  export declare function OmList({ oms, onSelect, }: {
11
15
  oms: DiscoveredOm[];
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import path from "node:path";
3
3
  import { useMemo } from "react";
4
- import { Text } from "ink";
4
+ import { Box, Text, useStdout } from "ink";
5
5
  import { SearchableList } from "./SearchableList.js";
6
6
  import { byNesting } from "../../../client/order.js";
7
7
  /** Show the om's path relative to `root` when it's inside it; else the absolute path. */
@@ -9,17 +9,31 @@ function displayPath(file, root) {
9
9
  const rel = path.relative(root, file);
10
10
  return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : file;
11
11
  }
12
+ function truncate(text, max) {
13
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
14
+ }
12
15
  /**
13
- * A searchable, arrow-navigable list of oms; Enter selects the highlighted one.
16
+ * The picker: a searchable, arrow-navigable list of oms; Enter runs the highlighted one.
17
+ *
18
+ * Each row is the om's name and its summary (or, without one, its path); the search matches
19
+ * both. Under the list, the highlighted om in full: its summary, where it is defined, and the
20
+ * actions its body calls with their tags — the same outline the skill file shows.
14
21
  *
15
- * Rows are ordered by {@link byNesting} from the working directory — the same root the path
16
- * column is rendered from, so the order matches what the column shows: the oms nearest the
17
- * top of the project first, deeper ones after, and any shown as an absolute path last.
22
+ * Rows are ordered by {@link byNesting} from the working directory — the same root the paths
23
+ * are rendered from, so the oms nearest the top of the project come first, deeper ones after,
24
+ * and any shown as an absolute path last.
18
25
  */
19
26
  export function OmList({ oms, onSelect, }) {
20
27
  const root = process.cwd();
21
28
  const sorted = useMemo(() => [...oms].sort(byNesting(root)), [oms, root]);
22
- // Column width so paths line up; capped so one long name can't push paths off-screen.
29
+ const { stdout } = useStdout();
30
+ const columns = stdout?.columns || 100;
31
+ // Column width so summaries line up; capped so one long name can't push them off-screen.
23
32
  const nameWidth = Math.min(30, oms.reduce((w, o) => Math.max(w, o.name.length), 0));
24
- return (_jsx(SearchableList, { items: sorted, getKey: (o) => o.name, getSearchText: (o) => o.name, onSelect: onSelect, renderRow: (o, selected) => (_jsxs(Text, { children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: displayPath(o.file, root) })] })) }));
33
+ const restWidth = Math.max(20, columns - nameWidth - 6);
34
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "omkit" }), _jsx(Text, { dimColor: true, children: ` · ${oms.length} om${oms.length === 1 ? "" : "s"}` })] }), _jsx(SearchableList, { items: sorted, getKey: (o) => `${o.file}\0${o.name}`, getSearchText: (o) => `${o.name} ${o.summary ?? ""}`, onSelect: onSelect, alwaysShowSearch: true, searchLabel: "search \u203A ", searchPlaceholder: "type to filter by name or summary", renderRow: (o, selected) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, color: selected ? "cyan" : undefined, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: truncate(o.summary ?? displayPath(o.file, root), restWidth) })] })), renderDetail: (o) => (o ? _jsx(OmDetail, { om: o, root: root }) : null) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 enter run \u00B7 esc clears search \u00B7 ctrl+c quit" })] }));
35
+ }
36
+ /** The highlighted om in full. */
37
+ function OmDetail({ om, root }) {
38
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: om.name }), om.summary ? (_jsx(Text, { children: om.summary })) : (_jsxs(Text, { dimColor: true, italic: true, children: ["No summary. Add .describe(", "{ summary }", ") to the om."] })), _jsx(Text, { dimColor: true, children: `${displayPath(om.file, root)}:${om.line}` }), om.calls?.length ? (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "calls " }), om.calls.map((c, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: " \u2192 " }) : null, c.name, c.tag ? _jsx(Text, { dimColor: true, children: ` [${c.tag}]` }) : null] }, `${c.name}\0${c.tag ?? ""}`)))] })) : null] }));
25
39
  }
@@ -14,6 +14,12 @@ export interface SearchableListProps<T> {
14
14
  max?: number;
15
15
  /** Prefix for the search line. Default "search: ". */
16
16
  searchLabel?: string;
17
+ /** Keep the search line on screen even when the list fits (it shows a placeholder while empty). */
18
+ alwaysShowSearch?: boolean;
19
+ /** Dim text shown on an empty, always-visible search line. */
20
+ searchPlaceholder?: string;
21
+ /** Drawn under the list for the highlighted item (`undefined` when nothing matches). */
22
+ renderDetail?: (item: T | undefined) => ReactNode;
17
23
  }
18
24
  /**
19
25
  * A searchable, arrow-navigable list with a scrolling viewport. Owns search, selection, and
@@ -21,4 +27,4 @@ export interface SearchableListProps<T> {
21
27
  * `search:` line and the `+N more` footer only appear once the list overflows the viewport
22
28
  * (or the user starts typing), so short lists stay a plain arrow list.
23
29
  */
24
- export declare function SearchableList<T>({ items, getKey, getSearchText, renderRow, onSelect, initialIndex, max, searchLabel, }: SearchableListProps<T>): ReactElement;
30
+ export declare function SearchableList<T>({ items, getKey, getSearchText, renderRow, onSelect, initialIndex, max, searchLabel, alwaysShowSearch, searchPlaceholder, renderDetail, }: SearchableListProps<T>): ReactElement;
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useRef, useState } from "react";
3
3
  import { Box, Text, useInput } from "ink";
4
4
  /** Hard cap on visible rows, regardless of `max`. */
@@ -9,7 +9,7 @@ const MAX_VISIBLE = 15;
9
9
  * `search:` line and the `+N more` footer only appear once the list overflows the viewport
10
10
  * (or the user starts typing), so short lists stay a plain arrow list.
11
11
  */
12
- export function SearchableList({ items, getKey, getSearchText, renderRow, onSelect, initialIndex = 0, max = MAX_VISIBLE, searchLabel = "search: ", }) {
12
+ export function SearchableList({ items, getKey, getSearchText, renderRow, onSelect, initialIndex = 0, max = MAX_VISIBLE, searchLabel = "search: ", alwaysShowSearch, searchPlaceholder, renderDetail, }) {
13
13
  // Refs hold the authoritative query/index: Ink can deliver several key events before React
14
14
  // re-renders, so the Enter handler must read refs, not a possibly-stale render closure. State
15
15
  // mirrors the refs only to drive the display.
@@ -38,6 +38,12 @@ export function SearchableList({ items, getKey, getSearchText, renderRow, onSele
38
38
  if (picked)
39
39
  onSelect(picked);
40
40
  }
41
+ else if (key.escape) {
42
+ queryRef.current = "";
43
+ indexRef.current = 0;
44
+ setQuery("");
45
+ setIndex(0);
46
+ }
41
47
  else if (key.backspace || key.delete) {
42
48
  queryRef.current = queryRef.current.slice(0, -1);
43
49
  indexRef.current = 0;
@@ -61,6 +67,10 @@ export function SearchableList({ items, getKey, getSearchText, renderRow, onSele
61
67
  const above = start;
62
68
  const below = filtered.length - (start + window);
63
69
  const overflows = items.length > Math.min(max, MAX_VISIBLE);
64
- const showSearch = overflows || query.length > 0;
65
- return (_jsxs(Box, { flexDirection: "column", children: [showSearch ? (_jsxs(Text, { dimColor: true, children: [searchLabel, query] })) : null, above > 0 ? _jsx(Text, { dimColor: true, children: `↑ ${above} more` }) : null, filtered.length === 0 ? _jsx(Text, { dimColor: true, children: "no matches" }) : null, visible.map((it, vi) => (_jsx(Box, { children: renderRow(it, start + vi === selected) }, getKey(it)))), below > 0 ? _jsx(Text, { dimColor: true, children: `+${below} more` }) : null] }));
70
+ const showSearch = alwaysShowSearch || overflows || query.length > 0;
71
+ return (_jsxs(Box, { flexDirection: "column", children: [showSearch ? (_jsx(SearchLine, { label: searchLabel, query: query, placeholder: searchPlaceholder })) : null, above > 0 ? _jsx(Text, { dimColor: true, children: `↑ ${above} more` }) : null, filtered.length === 0 ? _jsx(Text, { dimColor: true, children: "no matches" }) : null, visible.map((it, vi) => (_jsx(Box, { children: renderRow(it, start + vi === selected) }, getKey(it)))), below > 0 ? _jsx(Text, { dimColor: true, children: `+${below} more` }) : null, renderDetail ? renderDetail(filtered[selected]) : null] }));
72
+ }
73
+ /** The search line: what has been typed so far, or a dim hint while it is empty. */
74
+ function SearchLine({ label, query, placeholder, }) {
75
+ return (_jsxs(Text, { dimColor: true, children: [label, query ? _jsx(Text, { color: "cyan", children: query }) : placeholder] }));
66
76
  }
@@ -1,5 +1,6 @@
1
1
  import ts from "typescript";
2
2
  import path from "node:path";
3
+ import { checkedProgram } from "./tsCache.js";
3
4
  /** Typecheck the project (`tsc --noEmit`) and return per-file diagnostics. */
4
5
  export function runCheck(tsconfigPath) {
5
6
  const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
@@ -7,24 +8,20 @@ export function runCheck(tsconfigPath) {
7
8
  return [{ file: tsconfigPath, line: 0, message: msg(configFile.error) }];
8
9
  }
9
10
  const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.resolve(path.dirname(tsconfigPath)));
10
- const program = ts.createProgram({
11
- rootNames: parsed.fileNames,
12
- options: { ...parsed.options, noEmit: true },
13
- });
11
+ const checked = checkedProgram(parsed, tsconfigPath);
12
+ const program = checked.program;
14
13
  const canonical = (f) => ts.sys.useCaseSensitiveFileNames ? path.normalize(f) : path.normalize(f).toLowerCase();
15
14
  const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
16
15
  const out = [];
17
16
  for (const sf of program.getSourceFiles()) {
18
17
  if (!fileSet.has(canonical(sf.fileName)))
19
18
  continue;
20
- for (const d of [
21
- ...program.getSyntacticDiagnostics(sf),
22
- ...program.getSemanticDiagnostics(sf),
23
- ]) {
19
+ for (const d of [...program.getSyntacticDiagnostics(sf), ...checked.semanticDiagnostics(sf)]) {
24
20
  const line = d.start !== undefined ? sf.getLineAndCharacterOfPosition(d.start).line + 1 : 0;
25
21
  out.push({ file: sf.fileName, line, message: msg(d) });
26
22
  }
27
23
  }
24
+ checked.save();
28
25
  return out;
29
26
  }
30
27
  function msg(d) {
@@ -6,7 +6,8 @@ import { omHash } from "../foundation/ids.js";
6
6
  import { fsSafe } from "../foundation/fsSafe.js";
7
7
  import { canonicalPath } from "../foundation/canonicalPath.js";
8
8
  import { tsxLoader } from "./runner.js";
9
- import { chainRoot, importedNames, outlineBody } from "./outline.js";
9
+ import { chainRoot, importedNames, outlineBody, summaryOf } from "./outline.js";
10
+ import { checkedProgram } from "./tsCache.js";
10
11
  /**
11
12
  * Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
12
13
  * actions. Type errors in the user's oms/actions degrade to `warnings` (an editor-in-progress
@@ -19,10 +20,8 @@ export function discover(tsconfigPath) {
19
20
  const warnings = [];
20
21
  const { parsed, warnings: configWarnings } = loadProject(tsconfigPath);
21
22
  warnings.push(...configWarnings);
22
- const program = ts.createProgram({
23
- rootNames: parsed.fileNames,
24
- options: { ...parsed.options, noEmit: true },
25
- });
23
+ const checked = checkedProgram(parsed, tsconfigPath);
24
+ const program = checked.program;
26
25
  const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
27
26
  for (const sf of program.getSourceFiles()) {
28
27
  if (!fileSet.has(canonical(sf.fileName)))
@@ -32,7 +31,7 @@ export function discover(tsconfigPath) {
32
31
  // it, `omHash` hashes it, and Node caches modules by it. Canonicalise once here so the
33
32
  // whole chain agrees with the filesystem, and with the paths a real run reports.
34
33
  const file = canonicalPath(sf.fileName);
35
- for (const d of program.getSemanticDiagnostics(sf))
34
+ for (const d of checked.semanticDiagnostics(sf))
36
35
  warnings.push(formatDiagnostic(d));
37
36
  const names = omkitImports(sf);
38
37
  const outlines = outlinesIn(sf, names.om, importedNames(sf));
@@ -42,11 +41,12 @@ export function discover(tsconfigPath) {
42
41
  (node.expression.text === names.om || node.expression.text === names.step)) {
43
42
  const arg = node.arguments[0];
44
43
  if (arg && ts.isStringLiteralLike(arg)) {
45
- const calls = outlines.get(arg.text);
44
+ const { calls, summary } = outlines.get(arg.text) ?? {};
46
45
  oms.push({
47
46
  name: arg.text,
48
47
  file,
49
48
  line: lineOf(sf, node),
49
+ ...(summary !== undefined ? { summary } : {}),
50
50
  ...(calls?.length ? { calls } : {}),
51
51
  });
52
52
  }
@@ -73,10 +73,12 @@ export function discover(tsconfigPath) {
73
73
  }
74
74
  }
75
75
  }
76
+ checked.save();
76
77
  return { oms, actions, warnings };
77
78
  }
78
79
  /**
79
- * Outline every `om("name")…​.run(body)` chain in a file, keyed by om name.
80
+ * Outline every `om("name")…​.run(body)` chain in a file, keyed by om name, along with the
81
+ * chain's literal `.describe({ summary })`.
80
82
  *
81
83
  * Collected in its own pass rather than alongside the `om(...)` match, because a body is only
82
84
  * reachable from the `.run` end of the chain and the om name only from the other — and the
@@ -93,9 +95,12 @@ function outlinesIn(sf, omName, imported) {
93
95
  const root = chainRoot(node);
94
96
  const body = node.arguments[0];
95
97
  const name = root?.call.arguments[0];
96
- if (root?.name === omName && name && ts.isStringLiteralLike(name) && body) {
97
- if (ts.isFunctionLike(body))
98
- outlines.set(name.text, outlineBody(body, imported));
98
+ if (root?.name === omName && name && ts.isStringLiteralLike(name)) {
99
+ const summary = summaryOf(root);
100
+ outlines.set(name.text, {
101
+ ...(body && ts.isFunctionLike(body) ? { calls: outlineBody(body, imported) } : {}),
102
+ ...(summary !== undefined ? { summary } : {}),
103
+ });
99
104
  }
100
105
  }
101
106
  ts.forEachChild(node, visit);
@@ -33,3 +33,10 @@ export declare function chainRoot(node: ts.CallExpression): ChainRoot | undefine
33
33
  * not follow indirection. It answers "roughly what happens in here" and nothing stronger.
34
34
  */
35
35
  export declare function outlineBody(body: ts.Node, imported: ReadonlySet<string>): OmCall[];
36
+ /**
37
+ * The `.tag("…")` on a chain, taken nearest its root. `calls` is outermost-first, so the last
38
+ * match is the innermost — the tag attached directly to the action rather than to something
39
+ * further along the chain.
40
+ */
41
+ /** The `summary` of a `.describe({ summary: "…" })` in the chain, when it is a string literal. */
42
+ export declare function summaryOf(chain: ChainRoot): string | undefined;
@@ -86,6 +86,26 @@ export function outlineBody(body, imported) {
86
86
  * match is the innermost — the tag attached directly to the action rather than to something
87
87
  * further along the chain.
88
88
  */
89
+ /** The `summary` of a `.describe({ summary: "…" })` in the chain, when it is a string literal. */
90
+ export function summaryOf(chain) {
91
+ for (const call of chain.calls) {
92
+ const callee = call.expression;
93
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "describe")
94
+ continue;
95
+ const arg = call.arguments[0];
96
+ if (!arg || !ts.isObjectLiteralExpression(arg))
97
+ continue;
98
+ for (const prop of arg.properties) {
99
+ if (ts.isPropertyAssignment(prop) &&
100
+ ts.isIdentifier(prop.name) &&
101
+ prop.name.text === "summary" &&
102
+ ts.isStringLiteralLike(prop.initializer)) {
103
+ return prop.initializer.text;
104
+ }
105
+ }
106
+ }
107
+ return undefined;
108
+ }
89
109
  function tagOf(chain) {
90
110
  let tag;
91
111
  for (const call of chain.calls) {
@@ -13,6 +13,12 @@ export interface DiscoveredOm {
13
13
  readonly file: string;
14
14
  /** 1-based line of the `om(...)` call (informational). */
15
15
  readonly line: number;
16
+ /**
17
+ * The `.describe({ summary })` text, read statically — present only when it is written as a
18
+ * string literal. The authoritative value (whatever the expression evaluates to) comes from
19
+ * the import-based pass, `discoverRegistrations`.
20
+ */
21
+ readonly summary?: string;
16
22
  /**
17
23
  * A static approximation of what the body calls, in source order. Absent when the walk
18
24
  * found nothing recognisable. Never a contract — see {@link import("./outline.ts").outlineBody}
@@ -0,0 +1,27 @@
1
+ import ts from "typescript";
2
+ /**
3
+ * A type-checking view of the project that remembers the last check. Discovery and
4
+ * `omkit check` both build a full program and ask for every om/action file's semantic
5
+ * diagnostics; this keeps TypeScript's incremental state in a `.tsbuildinfo` file between
6
+ * processes, so a file whose content (and dependencies) didn't change isn't checked again.
7
+ *
8
+ * The program itself is still rebuilt every time — the saving is the checker's work, which
9
+ * grows with the project's own files. The cache is best-effort throughout: a missing,
10
+ * stale, or unwritable file only costs a full check, never a wrong answer (TypeScript
11
+ * validates the stored state against each file's content hash).
12
+ */
13
+ export interface CheckedProgram {
14
+ readonly program: ts.Program;
15
+ /** Semantic diagnostics for `sf` — from the cache when nothing it depends on changed. */
16
+ semanticDiagnostics(sf: ts.SourceFile): readonly ts.Diagnostic[];
17
+ /** Persist what this check learned. Never throws. */
18
+ save(): void;
19
+ }
20
+ export declare function checkedProgram(parsed: ts.ParsedCommandLine, tsconfigPath: string): CheckedProgram;
21
+ /**
22
+ * Where a project's build info lives: `node_modules/.cache/omkit/` nearest the tsconfig —
23
+ * the usual home for tool caches, already ignored by git — else the OS temp folder.
24
+ * Keyed by the tsconfig and the TypeScript version, so two projects (or a TS upgrade)
25
+ * never share state. `OMKIT_CACHE_DIR` moves it; `OMKIT_NO_CACHE=1` turns it off.
26
+ */
27
+ export declare function buildInfoFile(tsconfigPath: string): string | undefined;
@@ -0,0 +1,103 @@
1
+ import ts from "typescript";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { canonicalPath } from "../foundation/canonicalPath.js";
7
+ export function checkedProgram(parsed, tsconfigPath) {
8
+ const file = buildInfoFile(tsconfigPath);
9
+ if (!file)
10
+ return uncached(parsed);
11
+ const options = {
12
+ ...parsed.options,
13
+ noEmit: true,
14
+ incremental: true,
15
+ // TypeScript compares this against its own forward-slashed spelling of the path
16
+ tsBuildInfoFile: file.replace(/\\/g, "/"),
17
+ // the user's own composite/outDir settings describe their build, not this check
18
+ composite: false,
19
+ };
20
+ let builder;
21
+ try {
22
+ builder = ts.createIncrementalProgram({
23
+ rootNames: parsed.fileNames,
24
+ options,
25
+ host: ts.createIncrementalCompilerHost(options),
26
+ });
27
+ }
28
+ catch {
29
+ // a cache TypeScript can't read back (corrupt, foreign) — drop it and check from scratch
30
+ fs.rmSync(file, { force: true });
31
+ return uncached(parsed);
32
+ }
33
+ return {
34
+ program: builder.getProgram(),
35
+ semanticDiagnostics: (sf) => builder.getSemanticDiagnostics(sf),
36
+ save() {
37
+ try {
38
+ // with `noEmit`, emitting writes only the build info
39
+ builder.emit(undefined, (target, text) => writeAtomic(target, text));
40
+ }
41
+ catch {
42
+ // a cache that can't be written is just a cache miss next time
43
+ }
44
+ },
45
+ };
46
+ }
47
+ function uncached(parsed) {
48
+ const program = ts.createProgram({
49
+ rootNames: parsed.fileNames,
50
+ options: { ...parsed.options, noEmit: true },
51
+ });
52
+ return { program, semanticDiagnostics: (sf) => program.getSemanticDiagnostics(sf), save() { } };
53
+ }
54
+ /**
55
+ * Where a project's build info lives: `node_modules/.cache/omkit/` nearest the tsconfig —
56
+ * the usual home for tool caches, already ignored by git — else the OS temp folder.
57
+ * Keyed by the tsconfig and the TypeScript version, so two projects (or a TS upgrade)
58
+ * never share state. `OMKIT_CACHE_DIR` moves it; `OMKIT_NO_CACHE=1` turns it off.
59
+ */
60
+ export function buildInfoFile(tsconfigPath) {
61
+ if (process.env.OMKIT_NO_CACHE === "1")
62
+ return undefined;
63
+ const config = canonicalPath(path.resolve(tsconfigPath));
64
+ const key = createHash("sha1").update(`${config}\0${ts.version}`).digest("hex").slice(0, 12);
65
+ const name = `${path.basename(config, ".json")}-${key}.tsbuildinfo`;
66
+ for (const dir of candidateDirs(path.dirname(config))) {
67
+ try {
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ return path.join(dir, name);
70
+ }
71
+ catch {
72
+ // not writable — try the next place
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+ function candidateDirs(from) {
78
+ if (process.env.OMKIT_CACHE_DIR)
79
+ return [path.resolve(process.env.OMKIT_CACHE_DIR)];
80
+ const out = [];
81
+ for (let dir = from;; dir = path.dirname(dir)) {
82
+ if (fs.existsSync(path.join(dir, "node_modules"))) {
83
+ out.push(path.join(dir, "node_modules", ".cache", "omkit"));
84
+ break;
85
+ }
86
+ if (path.dirname(dir) === dir)
87
+ break;
88
+ }
89
+ out.push(path.join(os.tmpdir(), "omkit-cache"));
90
+ return out;
91
+ }
92
+ /** Write via a temp file + rename, so a concurrent reader (a picker, a check) never sees half a file. */
93
+ function writeAtomic(target, text) {
94
+ const tmp = `${target}.${process.pid}.tmp`;
95
+ fs.writeFileSync(tmp, text);
96
+ try {
97
+ fs.renameSync(tmp, target);
98
+ }
99
+ catch (error) {
100
+ fs.rmSync(tmp, { force: true });
101
+ throw error;
102
+ }
103
+ }
@@ -164,7 +164,8 @@ button { font: inherit; color: inherit; }
164
164
 
165
165
  .chart { position: relative; min-width: 0; }
166
166
  .chart svg { display: block; overflow: hidden; }
167
- .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; cursor: pointer; }
167
+ /* the row's hit target already spans the label — letting it through keeps the marks under it hoverable */
168
+ .chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
168
169
  .chart .hit { fill: transparent; cursor: pointer; }
169
170
  .chart .hit:hover + .bar { filter: brightness(1.15); }
170
171
  .chart .bar-label.outside { fill: var(--text); }
@@ -190,7 +191,7 @@ button { font: inherit; color: inherit; }
190
191
  .flow-seq.parallel { stroke-dasharray: 1 4; stroke-linecap: round; stroke-width: 2; opacity: 0.8; }
191
192
  .flow-arrowhead { fill: var(--text-3); }
192
193
  .flow-par { fill: var(--text-3); font-size: 10px; font-style: italic; }
193
- .flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; }
194
+ .flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; stroke: var(--panel); stroke-width: 4px; paint-order: stroke; }
194
195
  .flow-card text.sub { fill: var(--text-3); font-size: 11px; }
195
196
  .flow-card .tchip text { fill: var(--text); font-size: 10.5px; }
196
197
  .flow-card .tchip.more text { fill: var(--text-3); }
@@ -224,12 +225,12 @@ button { font: inherit; color: inherit; }
224
225
  .acc-b > .params { margin-bottom: 8px; }
225
226
  .acc-b .section-title:first-child { margin-top: 0; }
226
227
  .acc-file { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: var(--text-2); }
227
- .shots { display: grid; gap: 6px; }
228
- .shot { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
229
- .shot:hover { border-color: var(--accent); background: var(--accent-soft); }
230
- .shot .thumb { width: 64px; }
231
- .shot .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
232
- .shot .t { color: var(--text-3); font-size: 11px; font-variant-numeric: tabular-nums; }
228
+ .dshots { display: grid; gap: 6px; }
229
+ .dshot { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
230
+ .dshot:hover { border-color: var(--accent); background: var(--accent-soft); }
231
+ .dshot .thumb { width: 64px; }
232
+ .dshot .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
233
+ .dshot .t { color: var(--text-3); font-size: 11px; font-variant-numeric: tabular-nums; }
233
234
  .kv { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px; margin: 2px 0 12px; }
234
235
  .kv .k { color: var(--text-2); }
235
236
  .kv .v { min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
@@ -472,18 +473,13 @@ a.icon-btn { text-decoration: none; }
472
473
  #app.hl-on .flow-card.err-path rect.box { stroke: var(--fail); stroke-width: 1.2; stroke-dasharray: 4 3; }
473
474
  #app.hl-on .tree .row.err { background: rgba(239, 68, 68, 0.1); }
474
475
  .chart g.r, .flow-card, .tree .row { transition: opacity 0.15s; }
475
- /* cross-node links: waits (dashed), ref calls (solid), connectors between rows / cards */
476
- .xl-span { stroke-width: 3; stroke-linecap: round; cursor: pointer; }
477
- .xl-span.once { stroke-width: 2.5; stroke-dasharray: 4 3; stroke-linecap: butt; }
478
- .xl-wire { fill: none; stroke-width: 1.4; stroke-dasharray: 3 3; opacity: 0.85; cursor: pointer; }
479
- .xl-span:hover { stroke-width: 4.5; }
480
- .xl-hit { fill: none; stroke: transparent; stroke-width: 10; cursor: pointer; }
481
- #xl-arrow path { fill: var(--text-2); }
482
- .flow-xlink { cursor: pointer; }
483
- .flow-xlink path { fill: none; stroke-width: 1.4; stroke-dasharray: 4 4; opacity: 0.8; }
484
- .flow-xlink path.hit { stroke: transparent; stroke-width: 12; stroke-dasharray: none; }
485
- .flow-xlink:hover path:not(.hit) { stroke-width: 2.6; opacity: 1; }
486
- .flow-xlink text { font-size: 10.5px; fill: var(--text-2); stroke: var(--panel); stroke-width: 3px; paint-order: stroke; }
476
+ /* cross-node links: hollow points on the caller's row, chips in the flow */
477
+ .chart circle.mark.link { stroke-width: 2; }
478
+ .xwire { stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.55; pointer-events: none; }
479
+ .xwire.hot { stroke-width: 2; opacity: 1; }
480
+ .fwire { fill: none; stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.5; pointer-events: none; }
481
+ .fwire.hot { stroke-width: 2; opacity: 1; }
482
+ .flow-shots .shot rect[stroke] { stroke-width: 1.5; }
487
483
 
488
484
  @media (max-width: 1100px) {
489
485
  .bottom { grid-template-columns: minmax(0, 1fr); }