@baique/pi-web-sky 0.1.35 → 0.1.36

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 (294) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +16 -16
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/app-update/route.js +1 -1
  23. package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
  24. package/.next/server/app/api/cwd/validate/route.js +1 -1
  25. package/.next/server/app/api/default-cwd/route.js +1 -1
  26. package/.next/server/app/api/file-index/route.js +1 -1
  27. package/.next/server/app/api/files/[...path]/route.js +1 -1
  28. package/.next/server/app/api/mcp/route.js +1 -1
  29. package/.next/server/app/api/models/route.js +1 -1
  30. package/.next/server/app/api/plugins/route.js +1 -1
  31. package/.next/server/app/api/project-trust/route.js +1 -1
  32. package/.next/server/app/api/search/route.js +1 -1
  33. package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
  34. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  35. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
  36. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  37. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  38. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  39. package/.next/server/app/api/sessions/route.js +1 -1
  40. package/.next/server/app/api/skills/check/route.js +1 -1
  41. package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
  42. package/.next/server/app/api/skills/install/route.js +1 -1
  43. package/.next/server/app/api/skills/route.js +2 -31
  44. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/update/route.js +1 -1
  46. package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/route.js +1 -1
  48. package/.next/server/app/index.html +1 -1
  49. package/.next/server/app/index.rsc +5 -5
  50. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  51. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  52. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/page.js +2 -2
  54. package/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app-paths-manifest.json +16 -16
  56. package/.next/server/chunks/2404.js +1 -1
  57. package/.next/server/chunks/3500.js +30 -0
  58. package/.next/server/chunks/4226.js +2 -2
  59. package/.next/server/chunks/542.js +10 -5
  60. package/.next/server/chunks/5794.js +1 -1
  61. package/.next/server/chunks/6429.js +1 -1
  62. package/.next/server/chunks/898.js +1 -1
  63. package/.next/server/middleware-build-manifest.js +1 -1
  64. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  65. package/.next/server/pages/404.html +1 -1
  66. package/.next/server/pages/500.html +1 -1
  67. package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
  68. package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
  69. package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
  70. package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
  71. package/.next/static/css/422c7dc0faa04d89.css +1 -0
  72. package/.next/trace +8 -9
  73. package/.next/trace-build +1 -1
  74. package/bin/pi-web-sky.js +15 -20
  75. package/lib/agent-client.test.mjs +64 -0
  76. package/lib/agent-client.ts +53 -0
  77. package/lib/agent-event-connection.test.mjs +193 -0
  78. package/lib/agent-event-connection.ts +202 -0
  79. package/lib/agent-event-stream.test.mjs +155 -0
  80. package/lib/agent-event-stream.ts +132 -0
  81. package/lib/agent-event-wire.test.mjs +278 -0
  82. package/lib/agent-event-wire.ts +107 -0
  83. package/lib/agent-phase.ts +24 -0
  84. package/lib/allowed-roots.ts +31 -0
  85. package/lib/ansi.test.mjs +52 -0
  86. package/lib/ansi.ts +211 -0
  87. package/lib/api-types.ts +139 -0
  88. package/lib/app-update.test.mjs +27 -0
  89. package/lib/app-update.ts +28 -0
  90. package/lib/atomic-file.test.mjs +37 -0
  91. package/lib/atomic-file.ts +34 -0
  92. package/lib/audit-session.ts +235 -0
  93. package/lib/bash-output.test.mjs +63 -0
  94. package/lib/bash-output.ts +53 -0
  95. package/lib/bg-image.ts +129 -0
  96. package/lib/board-events.ts +83 -0
  97. package/lib/board-purge.test.mjs +66 -0
  98. package/lib/board-purge.ts +65 -0
  99. package/lib/board-search.test.mjs +110 -0
  100. package/lib/board-search.ts +63 -0
  101. package/lib/board-store.test.mjs +542 -0
  102. package/lib/board-store.ts +794 -0
  103. package/lib/board-types.ts +96 -0
  104. package/lib/board-utils.ts +17 -0
  105. package/lib/bounded-form-data.test.mjs +54 -0
  106. package/lib/bounded-form-data.ts +49 -0
  107. package/lib/browser-notifications.test.mjs +167 -0
  108. package/lib/browser-notifications.ts +104 -0
  109. package/lib/chat-lazy-load.test.mjs +140 -0
  110. package/lib/chat-lazy-load.ts +64 -0
  111. package/lib/clipboard-paths.test.mjs +140 -0
  112. package/lib/clipboard-paths.ts +192 -0
  113. package/lib/clipboard.ts +18 -0
  114. package/lib/compaction-summary.test.mjs +44 -0
  115. package/lib/compaction-summary.ts +34 -0
  116. package/lib/custom-ui-terminal.test.mjs +32 -0
  117. package/lib/custom-ui-terminal.ts +27 -0
  118. package/lib/deepseek-pricing.test.mjs +120 -0
  119. package/lib/deepseek-pricing.ts +71 -0
  120. package/lib/directory-browser.test.mjs +60 -0
  121. package/lib/directory-browser.ts +83 -0
  122. package/lib/draft-stash.test.mjs +91 -0
  123. package/lib/draft-stash.ts +102 -0
  124. package/lib/draft-store.ts +105 -0
  125. package/lib/dropdown-direction.ts +27 -0
  126. package/lib/extension-command.test.mjs +63 -0
  127. package/lib/extension-command.ts +81 -0
  128. package/lib/file-access.test.mjs +31 -0
  129. package/lib/file-access.ts +59 -0
  130. package/lib/file-dirent.test.mjs +56 -0
  131. package/lib/file-dirent.ts +17 -0
  132. package/lib/file-explorer-state.test.mjs +63 -0
  133. package/lib/file-explorer-state.ts +36 -0
  134. package/lib/file-fuzzy.test.mjs +29 -0
  135. package/lib/file-fuzzy.ts +189 -0
  136. package/lib/file-links.test.mjs +92 -0
  137. package/lib/file-links.ts +148 -0
  138. package/lib/file-paths.ts +43 -0
  139. package/lib/file-types.test.mjs +34 -0
  140. package/lib/file-types.ts +73 -0
  141. package/lib/file-upload.test.mjs +70 -0
  142. package/lib/file-upload.ts +59 -0
  143. package/lib/file-viewer-state.test.mjs +20 -0
  144. package/lib/file-viewer-state.ts +15 -0
  145. package/lib/frontmatter.test.mjs +55 -0
  146. package/lib/frontmatter.ts +87 -0
  147. package/lib/git-changes.test.mjs +52 -0
  148. package/lib/git-changes.ts +232 -0
  149. package/lib/git-status.ts +47 -0
  150. package/lib/git-types.ts +29 -0
  151. package/lib/http-dispatcher.test.mjs +83 -0
  152. package/lib/http-dispatcher.ts +86 -0
  153. package/lib/i18n/format.test.mjs +19 -0
  154. package/lib/i18n/format.ts +60 -0
  155. package/lib/i18n/messages/en.ts +662 -0
  156. package/lib/i18n/messages/zh-CN.ts +661 -0
  157. package/lib/i18n/registry.test.mjs +32 -0
  158. package/lib/i18n/registry.ts +43 -0
  159. package/lib/i18n/types.ts +15 -0
  160. package/lib/image-attachments.test.mjs +27 -0
  161. package/lib/image-attachments.ts +56 -0
  162. package/lib/initial-navigation.test.mjs +65 -0
  163. package/lib/initial-navigation.ts +15 -0
  164. package/lib/markdown.test.mjs +104 -0
  165. package/lib/markdown.ts +347 -0
  166. package/lib/message-display.test.mjs +130 -0
  167. package/lib/message-display.ts +47 -0
  168. package/lib/model-catalog.test.mjs +327 -0
  169. package/lib/model-catalog.ts +414 -0
  170. package/lib/model-discovery-auth.ts +58 -0
  171. package/lib/model-discovery.test.mjs +50 -0
  172. package/lib/model-discovery.ts +75 -0
  173. package/lib/model-id-preference.ts +46 -0
  174. package/lib/model-scope.test.mjs +193 -0
  175. package/lib/model-scope.ts +198 -0
  176. package/lib/models-cache.test.mjs +122 -0
  177. package/lib/models-cache.ts +90 -0
  178. package/lib/models-config-store.test.mjs +200 -0
  179. package/lib/models-config-store.ts +84 -0
  180. package/lib/next-config-esm.test.mjs +14 -0
  181. package/lib/next-config.test.mjs +13 -0
  182. package/lib/node-version.test.mjs +33 -0
  183. package/lib/normalize.test.mjs +54 -0
  184. package/lib/normalize.ts +63 -0
  185. package/lib/npx.ts +62 -0
  186. package/lib/panel-layout.test.mjs +56 -0
  187. package/lib/panel-layout.ts +52 -0
  188. package/lib/patch.test.mjs +110 -0
  189. package/lib/patch.ts +134 -0
  190. package/lib/path-security.ts +42 -0
  191. package/lib/paths.test.mjs +58 -0
  192. package/lib/paths.ts +71 -0
  193. package/lib/pi-types.ts +183 -0
  194. package/lib/pi-web-options.test.mjs +63 -0
  195. package/lib/process-lifecycle.test.mjs +83 -0
  196. package/lib/project-command-env.test.mjs +298 -0
  197. package/lib/project-command-env.ts +129 -0
  198. package/lib/project-groups.test.mjs +60 -0
  199. package/lib/project-groups.ts +53 -0
  200. package/lib/project-identity.test.mjs +37 -0
  201. package/lib/project-identity.ts +26 -0
  202. package/lib/project-tree.test.mjs +115 -0
  203. package/lib/project-tree.ts +176 -0
  204. package/lib/project-trust.test.mjs +117 -0
  205. package/lib/project-trust.ts +48 -0
  206. package/lib/prompt-recovery.test.mjs +33 -0
  207. package/lib/prompt-recovery.ts +47 -0
  208. package/lib/provider-api-key-route.test.mjs +12 -0
  209. package/lib/provider-credential-store.test.mjs +126 -0
  210. package/lib/provider-credential-store.ts +114 -0
  211. package/lib/provider-listing-runtime.ts +37 -0
  212. package/lib/provider-listing.test.mjs +114 -0
  213. package/lib/provider-listing.ts +118 -0
  214. package/lib/provider-services.ts +64 -0
  215. package/lib/request-security.test.mjs +256 -0
  216. package/lib/request-security.ts +136 -0
  217. package/lib/rpc-manager-shutdown.test.mjs +372 -0
  218. package/lib/rpc-manager-widgets.test.mjs +342 -0
  219. package/lib/rpc-manager.test.mjs +139 -0
  220. package/lib/rpc-manager.ts +1703 -0
  221. package/lib/rpc-session-info.test.mjs +93 -0
  222. package/lib/session-delete.test.mjs +53 -0
  223. package/lib/session-delete.ts +86 -0
  224. package/lib/session-file-references-core.ts +82 -0
  225. package/lib/session-file-references.test.mjs +91 -0
  226. package/lib/session-file-references.ts +29 -0
  227. package/lib/session-path.test.mjs +24 -0
  228. package/lib/session-path.ts +11 -0
  229. package/lib/session-reader.pagination.test.mjs +115 -0
  230. package/lib/session-reader.test.mjs +446 -0
  231. package/lib/session-reader.ts +548 -0
  232. package/lib/session-resolve.test.mjs +65 -0
  233. package/lib/session-row-context-menu.test.mjs +46 -0
  234. package/lib/session-row-context-menu.ts +29 -0
  235. package/lib/session-scanner.test.mjs +151 -0
  236. package/lib/session-scanner.ts +238 -0
  237. package/lib/session-search.test.mjs +173 -0
  238. package/lib/session-search.ts +284 -0
  239. package/lib/session-stats.ts +26 -0
  240. package/lib/session-timing.test.mjs +107 -0
  241. package/lib/session-timing.ts +45 -0
  242. package/lib/session-title.test.mjs +284 -0
  243. package/lib/session-title.ts +255 -0
  244. package/lib/skill-frontmatter.test.mjs +75 -0
  245. package/lib/skill-frontmatter.ts +40 -0
  246. package/lib/skill-lock.test.mjs +182 -0
  247. package/lib/skill-lock.ts +146 -0
  248. package/lib/skill-updates.test.mjs +182 -0
  249. package/lib/skill-updates.ts +263 -0
  250. package/lib/skills-service.ts +16 -0
  251. package/lib/slash-display.test.mjs +122 -0
  252. package/lib/slash-display.ts +30 -0
  253. package/lib/sqlite-db.test.mjs +140 -0
  254. package/lib/sqlite-db.ts +205 -0
  255. package/lib/startup-preferences.test.mjs +130 -0
  256. package/lib/startup-preferences.ts +65 -0
  257. package/lib/streaming-message.test.mjs +212 -0
  258. package/lib/streaming-message.ts +151 -0
  259. package/lib/subagent-widget.test.mjs +87 -0
  260. package/lib/subagent-widget.ts +109 -0
  261. package/lib/sync-room-server.mjs +137 -0
  262. package/lib/task-card-store.test.mjs +309 -0
  263. package/lib/task-card-store.ts +559 -0
  264. package/lib/task-scheduler.ts +498 -0
  265. package/lib/task-store.test.mjs +216 -0
  266. package/lib/task-store.ts +271 -0
  267. package/lib/terminal-input.test.mjs +46 -0
  268. package/lib/terminal-input.ts +63 -0
  269. package/lib/terminal-manager.test.mjs +71 -0
  270. package/lib/terminal-manager.ts +245 -0
  271. package/lib/tool-execution-progress.test.mjs +32 -0
  272. package/lib/tool-execution-progress.ts +29 -0
  273. package/lib/tool-names.ts +25 -0
  274. package/lib/tool-preset-preference.test.mjs +72 -0
  275. package/lib/tool-preset-preference.ts +41 -0
  276. package/lib/tool-presets.test.mjs +56 -0
  277. package/lib/tool-presets.ts +42 -0
  278. package/lib/turn-written-files.test.mjs +137 -0
  279. package/lib/turn-written-files.ts +60 -0
  280. package/lib/types.ts +349 -0
  281. package/lib/wallpaper-settings.ts +191 -0
  282. package/lib/web-auth.test.mjs +50 -0
  283. package/lib/web-auth.ts +45 -0
  284. package/lib/workspace-memory.test.mjs +129 -0
  285. package/lib/workspace-memory.ts +98 -0
  286. package/lib/worktree.test.mjs +50 -0
  287. package/lib/worktree.ts +250 -0
  288. package/package.json +3 -1
  289. package/server.mjs +34 -0
  290. package/.next/server/chunks/7513.js +0 -30
  291. package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
  292. package/.next/static/css/cf25164cb7c04d95.css +0 -1
  293. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
  294. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
@@ -0,0 +1,59 @@
1
+ import { readdirSync } from "fs";
2
+ import { homedir } from "os";
3
+ import path from "path";
4
+ import { getAdditionalAllowedRoots, normalizeSlashes } from "./allowed-roots";
5
+ import { isExistingPathWithinRoots, isPathWithinRoots } from "./path-security";
6
+ import { listAllSessions } from "./session-reader";
7
+ export { allowFileRoot, normalizeSlashes } from "./allowed-roots";
8
+ export { isWindowsAbsolutePath } from "./paths";
9
+
10
+ // Short-TTL cache for the allowed-roots set. Without this, every file list/read
11
+ // request re-scans every pi session on disk just to check access. 5s is short
12
+ // enough that newly-created cwds appear promptly; stored on globalThis so it
13
+ // survives Next.js hot-reload.
14
+ declare global {
15
+ var __piAllowedRootsCache: { roots: Set<string>; expiresAt: number } | undefined;
16
+ }
17
+
18
+ const ALLOWED_ROOTS_TTL_MS = 5_000;
19
+
20
+ export async function getAllowedFileRoots(): Promise<Set<string>> {
21
+ const now = Date.now();
22
+ const cached = globalThis.__piAllowedRootsCache;
23
+ if (cached && cached.expiresAt > now) return cached.roots;
24
+
25
+ const sessions = await listAllSessions();
26
+ const roots = new Set<string>();
27
+ for (const s of sessions) {
28
+ if (s.cwd) roots.add(normalizeSlashes(s.cwd));
29
+ // The project root (main repo shared by all worktrees) is browsable too —
30
+ // the project dropdown lists it even when only worktrees have sessions.
31
+ if (s.projectRoot) roots.add(normalizeSlashes(s.projectRoot));
32
+ }
33
+
34
+ // Also allow ~/pi-cwd-* directories created by the default-cwd endpoint.
35
+ try {
36
+ for (const name of readdirSync(homedir())) {
37
+ if (/^pi-cwd-\d{8}$/.test(name)) {
38
+ roots.add(normalizeSlashes(path.join(homedir(), name)));
39
+ }
40
+ }
41
+ } catch {
42
+ // ignore if home is unreadable
43
+ }
44
+
45
+ for (const root of getAdditionalAllowedRoots()) roots.add(root);
46
+
47
+ globalThis.__piAllowedRootsCache = { roots, expiresAt: now + ALLOWED_ROOTS_TTL_MS };
48
+ return roots;
49
+ }
50
+
51
+ /** Authorize a path lexically, without touching the filesystem. */
52
+ export function isFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
53
+ return isPathWithinRoots(target, allowedRoots);
54
+ }
55
+
56
+ /** Authorize an existing path after resolving symbolic links. */
57
+ export function isExistingFilePathAllowed(target: string, allowedRoots: Set<string>): boolean {
58
+ return isExistingPathWithinRoots(target, allowedRoots);
59
+ }
@@ -0,0 +1,56 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ async function loadSubject() {
8
+ return import("./file-dirent.ts");
9
+ }
10
+
11
+ test("uses Dirent types for regular files and directories", async () => {
12
+ const { resolveDirentIsDirectory } = await loadSubject();
13
+ const file = { isDirectory: () => false, isFile: () => true };
14
+ const directory = { isDirectory: () => true, isFile: () => false };
15
+
16
+ assert.equal(resolveDirentIsDirectory(file, "/unused/file"), false);
17
+ assert.equal(resolveDirentIsDirectory(directory, "/unused/directory"), true);
18
+ });
19
+
20
+ test("falls back to stat when the Dirent type is unknown", async (t) => {
21
+ const { resolveDirentIsDirectory } = await loadSubject();
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
23
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
24
+ const directoryPath = path.join(root, "directory");
25
+ fs.mkdirSync(directoryPath);
26
+
27
+ const unknown = { isDirectory: () => false, isFile: () => false };
28
+ assert.equal(resolveDirentIsDirectory(unknown, directoryPath), true);
29
+ });
30
+
31
+ test("follows directory symlinks and skips dangling symlinks", async (t) => {
32
+ const { resolveDirentIsDirectory } = await loadSubject();
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pi-web-dirent-"));
34
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
35
+ fs.mkdirSync(path.join(root, "target"));
36
+ try {
37
+ fs.symlinkSync("target", path.join(root, "directory-link"), "dir");
38
+ fs.symlinkSync("missing", path.join(root, "dangling-link"), "file");
39
+ } catch (error) {
40
+ if (error?.code === "EPERM") {
41
+ t.skip("Creating symbolic links requires additional privileges on this platform");
42
+ return;
43
+ }
44
+ throw error;
45
+ }
46
+
47
+ const symlink = { isDirectory: () => false, isFile: () => false };
48
+ assert.equal(
49
+ resolveDirentIsDirectory(symlink, path.join(root, "directory-link")),
50
+ true,
51
+ );
52
+ assert.equal(
53
+ resolveDirentIsDirectory(symlink, path.join(root, "dangling-link")),
54
+ null,
55
+ );
56
+ });
@@ -0,0 +1,17 @@
1
+ import fs from "fs";
2
+
3
+ type DirentType = Pick<fs.Dirent, "isDirectory" | "isFile">;
4
+
5
+ export function resolveDirentIsDirectory(
6
+ dirent: DirentType,
7
+ fullPath: string,
8
+ ): boolean | null {
9
+ if (dirent.isDirectory()) return true;
10
+ if (dirent.isFile()) return false;
11
+
12
+ try {
13
+ return fs.statSync(fullPath).isDirectory();
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createJiti } from "jiti";
4
+
5
+ const jiti = createJiti(import.meta.url);
6
+ const { loadExplorerOpen, saveExplorerOpen } = await jiti.import("./file-explorer-state.ts");
7
+
8
+ function createStorage(initial = {}) {
9
+ const values = new Map(Object.entries(initial));
10
+ return {
11
+ values,
12
+ getItem(key) {
13
+ return values.get(key) ?? null;
14
+ },
15
+ setItem(key, value) {
16
+ values.set(key, value);
17
+ },
18
+ };
19
+ }
20
+
21
+ test("defaults to an open file explorer", () => {
22
+ assert.equal(loadExplorerOpen(createStorage()), true);
23
+ });
24
+
25
+ test("saves and restores the file explorer panel state", () => {
26
+ const storage = createStorage();
27
+
28
+ saveExplorerOpen(false, storage);
29
+ assert.equal(loadExplorerOpen(storage), false);
30
+
31
+ saveExplorerOpen(true, storage);
32
+ assert.equal(loadExplorerOpen(storage), true);
33
+ });
34
+
35
+ test("falls back to open when browser storage is unavailable", () => {
36
+ const unavailable = {
37
+ getItem() { throw new Error("blocked"); },
38
+ setItem() { throw new Error("blocked"); },
39
+ };
40
+
41
+ assert.equal(loadExplorerOpen(unavailable), true);
42
+ assert.doesNotThrow(() => saveExplorerOpen(false, unavailable));
43
+ });
44
+
45
+ test("falls back when accessing browser storage throws", () => {
46
+ const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
47
+ const blockedWindow = {};
48
+ Object.defineProperty(blockedWindow, "localStorage", {
49
+ get() { throw new DOMException("blocked", "SecurityError"); },
50
+ });
51
+ Object.defineProperty(globalThis, "window", {
52
+ configurable: true,
53
+ value: blockedWindow,
54
+ });
55
+
56
+ try {
57
+ assert.equal(loadExplorerOpen(), true);
58
+ assert.doesNotThrow(() => saveExplorerOpen(false));
59
+ } finally {
60
+ if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
61
+ else delete globalThis.window;
62
+ }
63
+ });
@@ -0,0 +1,36 @@
1
+ const EXPLORER_OPEN_STORAGE_KEY = "pi-web:file-explorer:open";
2
+
3
+ interface StorageLike {
4
+ getItem(key: string): string | null;
5
+ setItem(key: string, value: string): void;
6
+ }
7
+
8
+ function getBrowserStorage(): StorageLike | null {
9
+ if (typeof window === "undefined") return null;
10
+ try {
11
+ return window.localStorage;
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+
17
+ export function loadExplorerOpen(storage: StorageLike | null = getBrowserStorage()): boolean {
18
+ if (!storage) return true;
19
+ try {
20
+ return storage.getItem(EXPLORER_OPEN_STORAGE_KEY) !== "false";
21
+ } catch {
22
+ return true;
23
+ }
24
+ }
25
+
26
+ export function saveExplorerOpen(
27
+ open: boolean,
28
+ storage: StorageLike | null = getBrowserStorage(),
29
+ ): void {
30
+ if (!storage) return;
31
+ try {
32
+ storage.setItem(EXPLORER_OPEN_STORAGE_KEY, String(open));
33
+ } catch {
34
+ // Persistence is best-effort; privacy mode and storage quotas must not break the explorer.
35
+ }
36
+ }
@@ -0,0 +1,29 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ async function loadSubject() {
5
+ return import("./file-fuzzy.ts");
6
+ }
7
+
8
+ test("builds closed file mentions and quotes paths containing spaces", async () => {
9
+ const { buildAtMentionText, buildFileAtMentionsText } = await loadSubject();
10
+
11
+ assert.equal(buildAtMentionText("notes/todo.md", false), "@notes/todo.md ");
12
+ assert.equal(buildAtMentionText("project files/design brief.md", false), "@\"project files/design brief.md\" ");
13
+ assert.equal(
14
+ buildFileAtMentionsText(["notes/todo.md", "project files/design brief.md"]),
15
+ "@notes/todo.md @\"project files/design brief.md\" ",
16
+ );
17
+ });
18
+
19
+ test("builds line-scoped file mentions", async () => {
20
+ const { buildFileLineMentionText } = await loadSubject();
21
+
22
+ assert.equal(buildFileLineMentionText("src/app.ts", 12, 12), "@src/app.ts:12 ");
23
+ assert.equal(buildFileLineMentionText("src/app.ts", 18, 12), "@src/app.ts:12-18 ");
24
+ assert.equal(
25
+ buildFileLineMentionText("project files/app.ts", 3, 9),
26
+ "@\"project files/app.ts\":3-9 ",
27
+ );
28
+ assert.equal(buildFileLineMentionText("src/app.ts", 0, 0), "@src/app.ts:1 ");
29
+ });
@@ -0,0 +1,189 @@
1
+ // Pure helpers for the chat input's @ file autocomplete. Mirrors the pi TUI's
2
+ // behavior: @ triggers at line start or after whitespace, entries are ranked
3
+ // with the TUI's scoreEntry ladder, and completions insert "@relative/path ".
4
+
5
+ export interface AtQueryMatch {
6
+ /** Index of the "@" character in the text */
7
+ start: number;
8
+ /** Text typed after the "@" (quotes stripped); may be empty */
9
+ query: string;
10
+ /** True when the token uses the @"..." quoted form */
11
+ quoted: boolean;
12
+ }
13
+
14
+ export interface FileIndexEntry {
15
+ /** Path relative to the session cwd, "/"-separated, no trailing slash */
16
+ path: string;
17
+ isDir: boolean;
18
+ }
19
+
20
+ /**
21
+ * Detect an @ file token immediately before the cursor. The @ must be at the
22
+ * start of the text or preceded by whitespace (same rule as the TUI), so
23
+ * emails like foo@bar never trigger. Supports the in-progress quoted form
24
+ * @"my dir/fi so drill-down into space-containing paths keeps working.
25
+ */
26
+ export function extractAtQuery(textBeforeCursor: string): AtQueryMatch | null {
27
+ const quoted = /(?:^|\s)@"([^"\n]*)$/.exec(textBeforeCursor);
28
+ if (quoted) {
29
+ return {
30
+ start: textBeforeCursor.length - (quoted[1].length + 2),
31
+ query: quoted[1],
32
+ quoted: true,
33
+ };
34
+ }
35
+ const plain = /(?:^|\s)@([^\s"]*)$/.exec(textBeforeCursor);
36
+ if (plain) {
37
+ return {
38
+ start: textBeforeCursor.length - (plain[1].length + 1),
39
+ query: plain[1],
40
+ quoted: false,
41
+ };
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function pathDepth(p: string): number {
47
+ let depth = 0;
48
+ for (let i = 0; i < p.length; i++) {
49
+ if (p[i] === "/") depth++;
50
+ }
51
+ return depth;
52
+ }
53
+
54
+ /**
55
+ * Build the entry list from the server's flat file list, deriving directory
56
+ * entries from file paths (the index API only returns files). Base order is
57
+ * shallow-first then alphabetical, which is what an empty @ query shows.
58
+ */
59
+ export function buildEntriesFromFiles(files: string[]): FileIndexEntry[] {
60
+ const dirs = new Set<string>();
61
+ for (const f of files) {
62
+ let idx = f.indexOf("/");
63
+ while (idx !== -1) {
64
+ dirs.add(f.slice(0, idx));
65
+ idx = f.indexOf("/", idx + 1);
66
+ }
67
+ }
68
+ const entries: FileIndexEntry[] = [];
69
+ for (const d of dirs) entries.push({ path: d, isDir: true });
70
+ for (const f of files) {
71
+ if (!f) continue;
72
+ entries.push({ path: f, isDir: false });
73
+ }
74
+ entries.sort((a, b) => pathDepth(a.path) - pathDepth(b.path) || a.path.localeCompare(b.path));
75
+ return entries;
76
+ }
77
+
78
+ function isSubsequence(needle: string, haystack: string): boolean {
79
+ if (!needle) return true;
80
+ let i = 0;
81
+ for (let j = 0; j < haystack.length && i < needle.length; j++) {
82
+ if (haystack[j] === needle[i]) i++;
83
+ }
84
+ return i === needle.length;
85
+ }
86
+
87
+ /**
88
+ * TUI scoreEntry ladder (exact 100 / prefix 80 / substring 50 / path substring
89
+ * 30, directories +10) plus a low-weight subsequence fallback so genuinely
90
+ * fuzzy queries like "chinp" still find components/ChatInput.tsx.
91
+ *
92
+ * Queries containing "/" are ranked against the full relative path instead of
93
+ * the basename — this is what makes drill-down work: after inserting "@src/",
94
+ * the query "src/" prefix-matches every entry inside src/ (and excludes the
95
+ * src directory itself, since "src" does not start with "src/").
96
+ */
97
+ function scoreEntry(entry: FileIndexEntry, lowerQuery: string): number {
98
+ const lowerPath = entry.path.toLowerCase();
99
+ let score = 0;
100
+ if (lowerQuery.includes("/")) {
101
+ if (lowerPath === lowerQuery) score = 100;
102
+ else if (lowerPath.startsWith(lowerQuery)) score = 80;
103
+ else if (lowerPath.includes(lowerQuery)) score = 50;
104
+ else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
105
+ } else {
106
+ const slash = lowerPath.lastIndexOf("/");
107
+ const lowerName = slash === -1 ? lowerPath : lowerPath.slice(slash + 1);
108
+ if (lowerName === lowerQuery) score = 100;
109
+ else if (lowerName.startsWith(lowerQuery)) score = 80;
110
+ else if (lowerName.includes(lowerQuery)) score = 50;
111
+ else if (lowerPath.includes(lowerQuery)) score = 30;
112
+ else if (isSubsequence(lowerQuery, lowerPath)) score = 10;
113
+ }
114
+ if (entry.isDir && score > 0) score += 10;
115
+ return score;
116
+ }
117
+
118
+ export const AT_RESULT_LIMIT = 20;
119
+
120
+ export function filterFileEntries(
121
+ entries: FileIndexEntry[],
122
+ query: string,
123
+ limit: number = AT_RESULT_LIMIT,
124
+ ): FileIndexEntry[] {
125
+ const lowerQuery = query.toLowerCase();
126
+ if (!lowerQuery) return entries.slice(0, limit);
127
+
128
+ const scored: Array<{ entry: FileIndexEntry; score: number }> = [];
129
+ for (const entry of entries) {
130
+ const score = scoreEntry(entry, lowerQuery);
131
+ if (score > 0) scored.push({ entry, score });
132
+ }
133
+ scored.sort((a, b) =>
134
+ b.score - a.score
135
+ || pathDepth(a.entry.path) - pathDepth(b.entry.path)
136
+ || a.entry.path.localeCompare(b.entry.path));
137
+ return scored.slice(0, limit).map((s) => s.entry);
138
+ }
139
+
140
+ export interface AtInsertion {
141
+ /** Text that replaces the @token */
142
+ text: string;
143
+ /** Caret position relative to the start of `text` after insertion */
144
+ cursorOffset: number;
145
+ }
146
+
147
+ /**
148
+ * Replacement for the @token when a suggestion is confirmed. Mirrors the
149
+ * TUI's buildCompletionValue/applyCompletion:
150
+ * - Files close the token: "@path " (quoted when the path contains spaces),
151
+ * caret after the trailing space.
152
+ * - Directories keep the menu open for drill-down: "@dir/" with no trailing
153
+ * space. Quoted directories are inserted CLOSED (@"my dir/") with the caret
154
+ * placed before the closing quote, so both further typing and manual
155
+ * completion keep the token well-formed.
156
+ */
157
+ export function buildAtInsertText(entryPath: string, isDir: boolean, forceQuotes = false): AtInsertion {
158
+ const p = isDir ? `${entryPath}/` : entryPath;
159
+ const needsQuotes = forceQuotes || p.includes(" ");
160
+ if (isDir) {
161
+ const text = needsQuotes ? `@"${p}"` : `@${p}`;
162
+ return { text, cursorOffset: needsQuotes ? text.length - 1 : text.length };
163
+ }
164
+ const text = needsQuotes ? `@"${p}" ` : `@${p} `;
165
+ return { text, cursorOffset: text.length };
166
+ }
167
+
168
+ /**
169
+ * Closed @mention for one-shot inserts (e.g. the file explorer's @ button).
170
+ * Unlike buildAtInsertText there is no drill-down: directories are closed
171
+ * too, with a trailing "/" and a trailing space.
172
+ */
173
+ export function buildAtMentionText(entryPath: string, isDir: boolean): string {
174
+ const p = isDir ? `${entryPath}/` : entryPath;
175
+ return p.includes(" ") ? `@"${p}" ` : `@${p} `;
176
+ }
177
+
178
+ /** Closed file @mention scoped to one logical line or an inclusive line range. */
179
+ export function buildFileLineMentionText(entryPath: string, startLine: number, endLine: number): string {
180
+ const firstLine = Math.max(1, Math.min(startLine, endLine));
181
+ const lastLine = Math.max(1, Math.max(startLine, endLine));
182
+ const pathMention = entryPath.includes(" ") ? `@"${entryPath}"` : `@${entryPath}`;
183
+ const lineSuffix = firstLine === lastLine ? `:${firstLine}` : `:${firstLine}-${lastLine}`;
184
+ return `${pathMention}${lineSuffix} `;
185
+ }
186
+
187
+ export function buildFileAtMentionsText(entryPaths: string[]): string {
188
+ return entryPaths.map((entryPath) => buildAtMentionText(entryPath, false)).join("");
189
+ }
@@ -0,0 +1,92 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ async function loadSubject() {
5
+ return import("./file-links.ts");
6
+ }
7
+
8
+ test("resolves absolute markdown file links and strips line suffixes", async () => {
9
+ const { resolveLocalFileHref } = await loadSubject();
10
+
11
+ assert.equal(
12
+ resolveLocalFileHref(
13
+ "/home/me/project/components/MarkdownBody.tsx:36",
14
+ "/home/me/project",
15
+ ),
16
+ "/home/me/project/components/MarkdownBody.tsx",
17
+ );
18
+ });
19
+
20
+ test("resolves absolute file links outside cwd", async () => {
21
+ const { resolveLocalFileHref } = await loadSubject();
22
+
23
+ assert.equal(
24
+ resolveLocalFileHref(
25
+ "/home/me/.codex/config.toml:12",
26
+ "/home/me/project",
27
+ ),
28
+ "/home/me/.codex/config.toml",
29
+ );
30
+ });
31
+
32
+ test("resolves relative markdown file links against cwd", async () => {
33
+ const { resolveLocalFileHref } = await loadSubject();
34
+
35
+ assert.equal(
36
+ resolveLocalFileHref("components/AppShell.tsx#L42", "/home/me/project"),
37
+ "/home/me/project/components/AppShell.tsx",
38
+ );
39
+ });
40
+
41
+ test("does not let relative links escape cwd", async () => {
42
+ const { resolveLocalFileHref } = await loadSubject();
43
+
44
+ assert.equal(
45
+ resolveLocalFileHref("../outside.md", "/home/me/project"),
46
+ null,
47
+ );
48
+ });
49
+
50
+ test("resolves preview links from the file directory within the project root", async () => {
51
+ const { resolveLocalFileHref } = await loadSubject();
52
+
53
+ assert.equal(
54
+ resolveLocalFileHref("../file.js", "/home/me/project/docs/nested", "/home/me/project"),
55
+ "/home/me/project/docs/file.js",
56
+ );
57
+ assert.equal(
58
+ resolveLocalFileHref("../../../outside.js", "/home/me/project/docs/nested", "/home/me/project"),
59
+ null,
60
+ );
61
+ });
62
+
63
+ test("does not treat app or external URLs as file links", async () => {
64
+ const { resolveLocalFileHref } = await loadSubject();
65
+
66
+ assert.equal(resolveLocalFileHref("/api/files/home/me/project/a.ts", "/home/me/project"), null);
67
+ assert.equal(resolveLocalFileHref("https://example.com/a.ts", "/home/me/project"), null);
68
+ assert.equal(resolveLocalFileHref("ftp://example.com/a.ts", "/home/me/project"), null);
69
+ assert.equal(resolveLocalFileHref("//example.com/a.ts", "/home/me/project"), null);
70
+ });
71
+
72
+ test("resolves Windows file URLs without a synthetic leading slash", async () => {
73
+ const { resolveLocalFileHref } = await loadSubject();
74
+
75
+ assert.equal(
76
+ resolveLocalFileHref("file:///C:/Users/me/project/file.txt:10", "C:/Users/me/project"),
77
+ "C:/Users/me/project/file.txt",
78
+ );
79
+ });
80
+
81
+ test("resolves UNC file URLs and backslash UNC paths", async () => {
82
+ const { resolveLocalFileHref } = await loadSubject();
83
+
84
+ assert.equal(
85
+ resolveLocalFileHref("file://server/share/project/file.txt", "/home/me/project"),
86
+ "//server/share/project/file.txt",
87
+ );
88
+ assert.equal(
89
+ resolveLocalFileHref("\\\\server\\share\\project\\file.txt", "/home/me/project"),
90
+ "//server/share/project/file.txt",
91
+ );
92
+ });