opencode-extended-sidebar 0.2.90

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.
Files changed (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Read-only git status for Files letters. No repo / no git → empty, never throws.
3
+ * Status is scoped to the listed paths — never the whole worktree.
4
+ * Spawn is async: the TUI keeps the last letters until git returns.
5
+ */
6
+ import { spawn } from "node:child_process"
7
+ import fs from "node:fs"
8
+ import path from "node:path"
9
+ import { fileStamp } from "../pware.oc.core.paths.js"
10
+
11
+ /** Porcelain XY letters only. `?` is untracked. Do not invent extras here. */
12
+ export type GitLetter = "M" | "A" | "D" | "R" | "C" | "U" | "T" | "?"
13
+
14
+ const GIT_PATH_CAP = 40
15
+ const GIT_DEBOUNCE_MS = 2500
16
+ const GIT_TIMEOUT_MS = 1500
17
+
18
+ export function findGitRoot(start?: string | null): string | null {
19
+ if (!start) return null
20
+ let dir = path.resolve(start)
21
+ for (let i = 0; i < 16; i++) {
22
+ try {
23
+ if (fs.existsSync(path.join(dir, ".git"))) return dir
24
+ } catch {
25
+ return null
26
+ }
27
+ const parent = path.dirname(dir)
28
+ if (parent === dir) break
29
+ dir = parent
30
+ }
31
+ return null
32
+ }
33
+
34
+ export function gitStatusStamp(projectRoot?: string | null): string {
35
+ const root = findGitRoot(projectRoot)
36
+ if (!root) return "0"
37
+ return [fileStamp(path.join(root, ".git")), fileStamp(path.join(root, ".git", "HEAD")), fileStamp(path.join(root, ".git", "index"))].join("|")
38
+ }
39
+
40
+ function porcelainLetter(xy: string): GitLetter | null {
41
+ if (xy === "??") return "?"
42
+ if (xy === "!!") return null
43
+ const x = xy[0] ?? " "
44
+ const y = xy[1] ?? " "
45
+ if (x === "U" || y === "U" || xy === "AA" || xy === "DD") return "U"
46
+ const rank = (c: string): GitLetter | null => {
47
+ if (c === "D") return "D"
48
+ if (c === "R") return "R"
49
+ if (c === "C") return "C"
50
+ if (c === "A") return "A"
51
+ if (c === "T") return "T"
52
+ if (c === "M") return "M"
53
+ return null
54
+ }
55
+ return rank(x) ?? rank(y)
56
+ }
57
+
58
+ function xIsRename(x: string): boolean {
59
+ return x === "R" || x === "C"
60
+ }
61
+
62
+ /** Parse `git status --porcelain -z` stdout. Exported for tests. */
63
+ export function parsePorcelainZ(buf: string): Map<string, GitLetter> {
64
+ const out = new Map<string, GitLetter>()
65
+ let i = 0
66
+ while (i < buf.length) {
67
+ const xy = buf.slice(i, i + 2)
68
+ if (xy.length < 2) break
69
+ i += 2
70
+ if (buf[i] === " ") i += 1
71
+ const z = buf.indexOf("\0", i)
72
+ if (z < 0) break
73
+ const p1 = buf.slice(i, z).replace(/\\/g, "/")
74
+ i = z + 1
75
+ if (xIsRename(xy[0] ?? "")) {
76
+ const z2 = buf.indexOf("\0", i)
77
+ if (z2 >= 0) i = z2 + 1
78
+ }
79
+ const letter = porcelainLetter(xy)
80
+ if (letter && p1) out.set(p1.toLowerCase(), letter)
81
+ }
82
+ return out
83
+ }
84
+
85
+ export function relToGitRoot(posixPath: string, root: string): string {
86
+ const file = posixPath.replace(/\\/g, "/")
87
+ const base = root.replace(/\\/g, "/").replace(/\/+$/, "")
88
+ const lower = file.toLowerCase()
89
+ const rootLower = base.toLowerCase()
90
+ if (lower.startsWith(`${rootLower}/`)) return lower.slice(rootLower.length + 1)
91
+ return lower.replace(/^\.\//, "")
92
+ }
93
+
94
+ /** Pathspecs for `git status`: repo paths only, deduped, capped. Exported for tests. */
95
+ export function relsFrom(posixPaths: string[], root: string): string[] {
96
+ const base = root.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
97
+ const seen = new Set<string>()
98
+ const out: string[] = []
99
+ for (const p of posixPaths) {
100
+ const file = p.replace(/\\/g, "/")
101
+ // git aborts the whole status with exit 128 on a single outside-repo
102
+ // pathspec — sessions touch temp/config files outside the tree all the time.
103
+ if (!file.toLowerCase().startsWith(`${base}/`)) continue
104
+ const rel = relToGitRoot(p, root)
105
+ if (!rel || seen.has(rel)) continue
106
+ seen.add(rel)
107
+ out.push(rel)
108
+ if (out.length >= GIT_PATH_CAP) break
109
+ }
110
+ return out
111
+ }
112
+
113
+ let cacheKey = ""
114
+ let cacheMarks = new Map<string, GitLetter>()
115
+ let cacheRoot: string | null = null
116
+ let lastPathKey = ""
117
+ let lastSpawn = 0
118
+ let pendingKey = ""
119
+ let spawnGen = 0
120
+ let inFlight = false
121
+
122
+ const listeners = new Set<() => void>()
123
+
124
+ function notify(): void {
125
+ for (const fn of listeners) {
126
+ try {
127
+ fn()
128
+ } catch {
129
+ // sidebar teardown
130
+ }
131
+ }
132
+ }
133
+
134
+ /** Re-render when an async git status lands. Returns unsubscribe. */
135
+ export function onGitMarksChange(fn: () => void): () => void {
136
+ listeners.add(fn)
137
+ return () => {
138
+ listeners.delete(fn)
139
+ }
140
+ }
141
+
142
+ /** Drop cached letters and in-flight spawn accounting (tests). */
143
+ export function resetGitCache(): void {
144
+ spawnGen += 1
145
+ inFlight = false
146
+ cacheKey = ""
147
+ pendingKey = ""
148
+ lastPathKey = ""
149
+ lastSpawn = 0
150
+ cacheRoot = null
151
+ cacheMarks = new Map()
152
+ }
153
+
154
+ function runGit(root: string, rels: string[], key: string, gen: number): void {
155
+ let child: ReturnType<typeof spawn>
156
+ try {
157
+ child = spawn(
158
+ "git",
159
+ ["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--", ...rels],
160
+ { cwd: root, windowsHide: true },
161
+ )
162
+ } catch {
163
+ if (gen === spawnGen) inFlight = false
164
+ return
165
+ }
166
+ let out = ""
167
+ const timer = setTimeout(() => {
168
+ try {
169
+ child.kill()
170
+ } catch {
171
+ // already gone
172
+ }
173
+ }, GIT_TIMEOUT_MS)
174
+ child.stdout?.setEncoding("utf8")
175
+ child.stdout?.on("data", (chunk: string) => {
176
+ out += chunk
177
+ })
178
+ child.stderr?.resume()
179
+ const finish = (ok: boolean) => {
180
+ clearTimeout(timer)
181
+ if (gen !== spawnGen) return
182
+ inFlight = false
183
+ cacheRoot = root
184
+ cacheKey = key
185
+ if (ok) cacheMarks = parsePorcelainZ(out)
186
+ notify()
187
+ }
188
+ child.on("error", () => finish(false))
189
+ child.on("close", (code) => finish(code === 0))
190
+ }
191
+
192
+ /**
193
+ * Last known porcelain letters. Never waits on git — a stale map is returned
194
+ * and a spawn is scheduled when the cache key is cold (2.5 s debounce on index noise).
195
+ */
196
+ export function readGitMarksFor(
197
+ posixPaths: string[],
198
+ projectRoot?: string | null,
199
+ ): {
200
+ root: string | null
201
+ marks: Map<string, GitLetter>
202
+ } {
203
+ const root = findGitRoot(projectRoot)
204
+ if (!root || posixPaths.length === 0) {
205
+ return { root, marks: new Map() }
206
+ }
207
+ const rels = relsFrom(posixPaths, root)
208
+ if (rels.length === 0) {
209
+ return { root, marks: new Map() }
210
+ }
211
+ const pathKey = rels.slice().sort().join("\0")
212
+ const stamp = gitStatusStamp(projectRoot)
213
+ const key = `${root}|${stamp}|${pathKey}`
214
+ cacheRoot = root
215
+ if (key === cacheKey) return { root, marks: cacheMarks }
216
+ if (inFlight && pendingKey === key) return { root, marks: cacheMarks }
217
+
218
+ const now = Date.now()
219
+ if (pathKey === lastPathKey && lastSpawn > 0 && now - lastSpawn < GIT_DEBOUNCE_MS) {
220
+ return { root, marks: cacheMarks }
221
+ }
222
+
223
+ pendingKey = key
224
+ lastPathKey = pathKey
225
+ lastSpawn = now
226
+ spawnGen += 1
227
+ inFlight = true
228
+ runGit(root, rels, key, spawnGen)
229
+ return { root, marks: cacheMarks }
230
+ }
231
+
232
+ export function gitLetterFor(posixPath: string, projectRoot?: string | null): GitLetter | null {
233
+ const { root, marks } = readGitMarksFor([posixPath], projectRoot)
234
+ if (!root || marks.size === 0) return null
235
+ const rel = relToGitRoot(posixPath, root)
236
+ return marks.get(rel) ?? marks.get(posixPath.replace(/\\/g, "/").toLowerCase()) ?? null
237
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Ignore sources for the Files panel: the project `.gitignore` (only when
3
+ * `skipGitignore` is on) and `.oesignore` (always, when present). Both use the
4
+ * `ignore` package for full gitignore semantics.
5
+ */
6
+ import fs from "node:fs"
7
+ import path from "node:path"
8
+ import ignore from "ignore"
9
+ import { fileStamp } from "../pware.oc.core.paths.js"
10
+ import { createStampCache } from "../pware.oc.core.cache.js"
11
+
12
+ export function gitignorePath(projectRoot?: string | null): string | null {
13
+ if (!projectRoot) return null
14
+ return path.join(projectRoot, ".gitignore")
15
+ }
16
+
17
+ export function oesignorePath(projectRoot?: string | null): string | null {
18
+ if (!projectRoot) return null
19
+ return path.join(projectRoot, ".oesignore")
20
+ }
21
+
22
+ /** Stamp both ignore files so the fingerprint reloads when either changes. */
23
+ export function gitignoreStamp(projectRoot?: string | null): string {
24
+ return [gitignorePath(projectRoot), oesignorePath(projectRoot)].map(fileStamp).join("|")
25
+ }
26
+
27
+ function readIfExists(p: string | null): string | null {
28
+ if (!p) return null
29
+ try {
30
+ if (!fs.existsSync(p)) return null
31
+ return fs.readFileSync(p, "utf8")
32
+ } catch {
33
+ return null
34
+ }
35
+ }
36
+
37
+ /** Absolute/relative path folded to a project-relative posix path, or null. */
38
+ function toRelPath(posixFile: string, projectRoot: string): string | null {
39
+ const root = projectRoot.replace(/\\/g, "/").replace(/\/+$/, "")
40
+ const file = posixFile.replace(/\\/g, "/").replace(/\/+$/, "")
41
+ if (!root) return null
42
+ if (file.toLowerCase().startsWith(`${root.toLowerCase()}/`)) return file.slice(root.length + 1)
43
+ if (/^[a-z]:\//i.test(file) || file.startsWith("/")) return null
44
+ return file.replace(/^\.\//, "")
45
+ }
46
+
47
+ function compile(patterns: string | null): ReturnType<typeof ignore> {
48
+ const ig = ignore()
49
+ if (patterns != null) ig.add(patterns)
50
+ return ig
51
+ }
52
+
53
+ type IgnoreSet = { gitignore: ReturnType<typeof ignore>; oesignore: ReturnType<typeof ignore> }
54
+
55
+ const ignoreCache = createStampCache<IgnoreSet>()
56
+
57
+ function setFor(projectRoot: string): IgnoreSet {
58
+ return ignoreCache.get(`${projectRoot}|${gitignoreStamp(projectRoot)}`, () => ({
59
+ gitignore: compile(readIfExists(gitignorePath(projectRoot))),
60
+ oesignore: compile(readIfExists(oesignorePath(projectRoot))),
61
+ }))
62
+ }
63
+
64
+ function ignoredBy(
65
+ which: keyof IgnoreSet,
66
+ posixPath: string,
67
+ projectRoot?: string | null,
68
+ ): boolean {
69
+ if (!projectRoot) return false
70
+ const rel = toRelPath(posixPath, projectRoot)
71
+ if (!rel) return false
72
+ try {
73
+ return setFor(projectRoot)[which].ignores(rel)
74
+ } catch {
75
+ return false
76
+ }
77
+ }
78
+
79
+ /** True when the path matches the project's root `.gitignore`. */
80
+ export function ignoredByGitignore(posixPath: string, projectRoot?: string | null): boolean {
81
+ return ignoredBy("gitignore", posixPath, projectRoot)
82
+ }
83
+
84
+ /** True when the path matches the project's `.oesignore` (always on when present). */
85
+ export function ignoredByOesignore(posixPath: string, projectRoot?: string | null): boolean {
86
+ return ignoredBy("oesignore", posixPath, projectRoot)
87
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * pware.oc.core
3
+ *
4
+ * Shared infrastructure + pure logic. Core never imports a domain or the UI.
5
+ */
6
+ export * from "./pware.oc.core.cache.js"
7
+ export * from "./pware.oc.core.clipboard.js"
8
+ export * from "./pware.oc.core.debug.js"
9
+ export * from "./pware.oc.core.events.js"
10
+ export * from "./pware.oc.core.layout.js"
11
+ export * from "./pware.oc.core.oes.js"
12
+ export * from "./pware.oc.core.paths.js"
13
+ export * from "./pware.oc.core.preview.js"
14
+ export * from "./pware.oc.core.pulse.js"
15
+ export * from "./pware.oc.core.sqlite.js"
16
+ export * from "./pware.oc.core.status.js"
17
+ export * from "./pware.oc.core.bus.js"
18
+ export * from "./git/index.js"
@@ -0,0 +1,42 @@
1
+ export type PwareEvent = {
2
+ type: string
3
+ ts: number
4
+ data?: unknown
5
+ }
6
+
7
+ export type PwareEventListener = (evt: PwareEvent) => void
8
+
9
+ export type PwareEventBus = {
10
+ emit: (evt: PwareEvent) => void
11
+ on: (type: string, listener: PwareEventListener) => () => void
12
+ off: (type: string, listener: PwareEventListener) => void
13
+ }
14
+
15
+ export function createEventBus(): PwareEventBus {
16
+ const byType = new Map<string, Set<PwareEventListener>>()
17
+
18
+ const off = (type: string, listener: PwareEventListener): void => {
19
+ const set = byType.get(type)
20
+ if (!set) return
21
+ set.delete(listener)
22
+ if (set.size === 0) byType.delete(type)
23
+ }
24
+
25
+ const on = (type: string, listener: PwareEventListener): (() => void) => {
26
+ const set = byType.get(type)
27
+ if (set) {
28
+ set.add(listener)
29
+ } else {
30
+ byType.set(type, new Set([listener]))
31
+ }
32
+ return () => off(type, listener)
33
+ }
34
+
35
+ const emit = (evt: PwareEvent): void => {
36
+ const set = byType.get(evt.type)
37
+ if (!set || set.size === 0) return
38
+ for (const listener of set) listener(evt)
39
+ }
40
+
41
+ return { emit, on, off }
42
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Stamp-keyed in-memory cache. Optional TTL for filesystem snapshots.
3
+ */
4
+
5
+ export function createStampCache<T>(opts?: { ttlMs?: number }): {
6
+ get: (key: string, load: () => T) => T
7
+ peek: (key: string) => T | undefined
8
+ set: (key: string, value: T) => void
9
+ reset: () => void
10
+ } {
11
+ const ttl = opts?.ttlMs
12
+ let cacheKey = ""
13
+ let cached: T | undefined
14
+ let at = 0
15
+ return {
16
+ get(key, load) {
17
+ const now = Date.now()
18
+ if (
19
+ key &&
20
+ key === cacheKey &&
21
+ cached !== undefined &&
22
+ (ttl == null || now - at < ttl)
23
+ ) {
24
+ return cached
25
+ }
26
+ const next = load()
27
+ cacheKey = key
28
+ cached = next
29
+ at = now
30
+ return next
31
+ },
32
+ peek(key) {
33
+ if (!key || key !== cacheKey || cached === undefined) return undefined
34
+ if (ttl != null && Date.now() - at >= ttl) return undefined
35
+ return cached
36
+ },
37
+ set(key, value) {
38
+ cacheKey = key
39
+ cached = value
40
+ at = Date.now()
41
+ },
42
+ reset() {
43
+ cacheKey = ""
44
+ cached = undefined
45
+ at = 0
46
+ },
47
+ }
48
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Copy text to the system clipboard without npm deps.
3
+ * Native tools are async so a click never freezes the TUI.
4
+ * Windows prefers clip.exe; PowerShell is last resort (slow to start).
5
+ * OSC 52 is always sent so SSH / remote terminals can still receive the copy.
6
+ */
7
+ import { spawn } from "node:child_process"
8
+ import os from "node:os"
9
+
10
+ const COPY_TIMEOUT_MS = 1500
11
+
12
+ /** Terminal clipboard sequence (OSC 52). Exported for tests. */
13
+ export function osc52Payload(text: string): string {
14
+ return `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`
15
+ }
16
+
17
+ function writeOsc52(text: string): boolean {
18
+ try {
19
+ process.stdout.write(osc52Payload(text))
20
+ return true
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ function pipe(command: string, args: string[], input: string | Buffer): Promise<boolean> {
27
+ return new Promise((resolve) => {
28
+ let child: ReturnType<typeof spawn>
29
+ try {
30
+ child = spawn(command, args, {
31
+ windowsHide: true,
32
+ stdio: ["pipe", "ignore", "ignore"],
33
+ })
34
+ } catch {
35
+ resolve(false)
36
+ return
37
+ }
38
+ let done = false
39
+ const finish = (ok: boolean) => {
40
+ if (done) return
41
+ done = true
42
+ clearTimeout(timer)
43
+ resolve(ok)
44
+ }
45
+ const timer = setTimeout(() => {
46
+ try {
47
+ child.kill()
48
+ } catch {
49
+ // already gone
50
+ }
51
+ finish(false)
52
+ }, COPY_TIMEOUT_MS)
53
+ child.on("error", () => finish(false))
54
+ child.on("close", (code) => finish(code === 0))
55
+ try {
56
+ if (!child.stdin) {
57
+ finish(false)
58
+ return
59
+ }
60
+ child.stdin.end(input)
61
+ } catch {
62
+ finish(false)
63
+ }
64
+ })
65
+ }
66
+
67
+ /** clip.exe expects UTF-16 LE with BOM for non-ASCII. */
68
+ function clipUtf16(text: string): Buffer {
69
+ return Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, "utf16le")])
70
+ }
71
+
72
+ export async function copyText(text: string): Promise<boolean> {
73
+ if (!text) return false
74
+ const osc = writeOsc52(text)
75
+ const platform = os.platform()
76
+ if (platform === "win32") {
77
+ if (await pipe("clip", [], clipUtf16(text))) return true
78
+ if (
79
+ await pipe(
80
+ "powershell.exe",
81
+ ["-NoProfile", "-NonInteractive", "-Command", "$input | Set-Clipboard"],
82
+ text,
83
+ )
84
+ ) {
85
+ return true
86
+ }
87
+ return osc
88
+ }
89
+ if (platform === "darwin") {
90
+ if (await pipe("pbcopy", [], text)) return true
91
+ return osc
92
+ }
93
+ if (process.env.WAYLAND_DISPLAY && (await pipe("wl-copy", [], text))) return true
94
+ if (await pipe("xclip", ["-selection", "clipboard"], text)) return true
95
+ if (await pipe("xsel", ["--clipboard", "--input"], text)) return true
96
+ return osc
97
+ }