pi-studio 0.9.13 → 0.9.15

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/CHANGELOG.md CHANGED
@@ -4,10 +4,36 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.9.15] — 2026-05-23
8
+
9
+ ### Added
10
+ - Added a first-cut right-pane **Files** view for browsing the current Studio resource directory, opening folders, opening text/code documents in the editor, previewing local PDFs/images, copying paths, and revealing files in the file manager.
11
+ - Added text-file recognition for extensionless/dotfile project files such as `.gitignore`, `.gitattributes`, `.env`, `Dockerfile`, `Makefile`, `Justfile`, `LICENSE`, and `README`.
12
+
13
+ ### Changed
14
+ - The Files view now treats the Pi session directory/resource directory as its navigation root, while opening initially near the active file when applicable.
15
+ - Renamed **Clear editor** to **Reset editor** and clarified that it resets the tab to a fresh blank draft without changing saved files or responses.
16
+ - Browser-tab workspace restoration now uses per-tab session storage instead of shared local storage.
17
+
18
+ ### Fixed
19
+ - Fixed editor-only companion tabs inheriting a Files right-pane state from another Studio tab after opening a document in a new editor.
20
+
21
+ ## [0.9.14] — 2026-05-22
22
+
23
+ ### Added
24
+ - Added active-pane text-size shortcuts: Alt/Option+= to increase, Alt/Option+- to decrease, and Alt/Option+0 to reset editor or right-pane text size when not editing text.
25
+
26
+ ### Changed
27
+ - Blank and last-response Studio launches now default the working directory/resource context to the current Pi directory.
28
+ - Made the working-directory label use normal muted text styling instead of standing out like a primary control.
29
+
30
+ ### Fixed
31
+ - Fixed the keyboard-shortcuts overlay layout so shortcut groups are no longer clipped by the global pane section styles.
32
+
7
33
  ## [0.9.13] — 2026-05-22
8
34
 
9
35
  ### Added
10
- - Added browser-refresh restoration for the Studio editor workspace, plus an explicit **Clear editor** action for discarding the restored browser draft without touching saved files or response history.
36
+ - Added browser-refresh restoration for the Studio editor workspace, plus an explicit **Reset editor** action for discarding the restored browser draft without touching saved files or response history.
11
37
  - Added local preview-link handling for rendered document previews and sandboxed HTML previews: local PDF links open in Studio's embedded PDF focus viewer, local image links open in a local image preview, text/code/document links can open in a new editor tab, and right-clicking local links offers **Open here**, **Reveal in file manager**, and **Copy path** where applicable.
12
38
  - Brought editor-only Studio closer to full Studio for document-local controls: pane resizing/focus shortcuts now work, and the annotation-header toggle remains available.
13
39
 
package/README.md CHANGED
@@ -23,10 +23,11 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
23
23
  - Includes a global **Zen** mode for hiding secondary Studio chrome without changing the current left/right pane layout
24
24
  - Runs editor text directly, asks for structured critique (auto/writing/code focus), or opens **Quiz me** for a Studio-native active-recall loop over the current editor text, selection, current file, folder, or repo, with optional focus guidance for shaping question selection
25
25
  - Includes a live **Working** view for following current model/tool activity, with `All` / `Thinking` / `Tools` filters, image previews for image-producing tool outputs, plus **Load visible into editor** and **Copy visible** actions; when cycling response history, Working follows saved working details for the selected response when available
26
+ - Includes a right-pane **Files** view for browsing the current Pi session/resource directory, opening folders, loading text/code documents into the editor, previewing PDFs/images, copying paths, and revealing files in the file manager
26
27
  - Includes an optional tmux-backed **REPL** view for Shell, Python, IPython, Julia, R, GHCi, and Clojure sessions, with Raw/Literate send modes, `Cmd/Ctrl+Shift+Enter` **Send to REPL**, session start/stop/interrupt controls, a compact refresh-persistent **Studio REPL Record** of user and Pi-sent code, a secondary raw tmux mirror, agent-facing `studio_repl_status` / `studio_repl_send` tools, and Markdown/PDF/HTML export
27
28
  - Includes a local persistent scratchpad for quick notes you want to keep out of the main editor until you're ready to copy or insert them
28
29
  - Includes a docked **Outline** rail for navigating document structure in the current editor text, with clickable entries that jump in the raw editor and reveal matching preview locations when available
29
- - Restores the current browser-tab editor workspace after refresh and provides an explicit **Clear editor** action when you want to discard the restored draft without changing responses or saved files
30
+ - Restores the current browser-tab editor workspace after refresh and provides an explicit **Reset editor** action when you want to discard the restored draft and return the tab to a fresh blank draft without changing responses or saved files
30
31
  - Turns local preview links, including links inside sandboxed HTML previews, into Studio actions: PDFs open in the embedded viewer, text/code/document links can open in a new editor tab, and right-click menus provide **Open here**, **Reveal in file manager**, and **Copy path** for local resources
31
32
  - Includes local comments anchored to selections/lines, shown in a docked **Comments** rail, with transient **Comment** / **Jump** actions from raw-editor selections plus editor-preview selections for Markdown, LaTeX, and code/text/diff previews, alongside optional inline `[an: ...]` toggles when you want comments reflected in the document text
32
33
  - Browses response history (`Prev/Next/Last`) and loads either:
@@ -1955,6 +1955,19 @@
1955
1955
  const DEFAULT_RESPONSE_FONT_SIZE = studioUiRefreshEnabled ? 13.5 : 15;
1956
1956
  let editorFontSize = DEFAULT_EDITOR_FONT_SIZE;
1957
1957
  let responseFontSize = DEFAULT_RESPONSE_FONT_SIZE;
1958
+ let fileBrowserState = {
1959
+ rootDir: "",
1960
+ currentDir: "",
1961
+ relativeDir: "",
1962
+ parentDir: null,
1963
+ entries: [],
1964
+ omitted: 0,
1965
+ omittedIgnored: 0,
1966
+ loading: false,
1967
+ error: "",
1968
+ loaded: false,
1969
+ };
1970
+ let fileBrowserLoadNonce = 0;
1958
1971
  let studioUiRefreshUi = null;
1959
1972
  let studioZenModeEnabled = readStudioZenModeEnabled();
1960
1973
  if (studioUiRefreshEnabled && document.body) {
@@ -2174,6 +2187,49 @@
2174
2187
  scheduleResponsePaneRepaintNudge();
2175
2188
  }
2176
2189
 
2190
+ function getActivePaneTextSizeConfig() {
2191
+ if (activePane === "right") {
2192
+ return {
2193
+ label: "Right pane text size",
2194
+ value: responseFontSize,
2195
+ defaultValue: DEFAULT_RESPONSE_FONT_SIZE,
2196
+ options: RESPONSE_FONT_SIZE_OPTIONS,
2197
+ setValue: setResponseFontSize,
2198
+ };
2199
+ }
2200
+ return {
2201
+ label: "Editor text size",
2202
+ value: editorFontSize,
2203
+ defaultValue: DEFAULT_EDITOR_FONT_SIZE,
2204
+ options: EDITOR_FONT_SIZE_OPTIONS,
2205
+ setValue: setEditorFontSize,
2206
+ };
2207
+ }
2208
+
2209
+ function getNextStudioFontSizeOption(currentValue, options, defaultValue, direction) {
2210
+ const normalized = normalizeStudioFontSize(currentValue, options, defaultValue);
2211
+ const currentIndex = Math.max(0, options.findIndex((option) => Math.abs(option - normalized) < 0.001));
2212
+ const nextIndex = Math.max(0, Math.min(options.length - 1, currentIndex + direction));
2213
+ return options[nextIndex];
2214
+ }
2215
+
2216
+ function adjustActivePaneTextSize(direction) {
2217
+ const config = getActivePaneTextSizeConfig();
2218
+ const nextSize = getNextStudioFontSizeOption(config.value, config.options, config.defaultValue, direction);
2219
+ if (Math.abs(nextSize - config.value) < 0.001) {
2220
+ setStatus(config.label + " already at " + formatStudioFontSizeLabel(nextSize) + ".", "warning");
2221
+ return;
2222
+ }
2223
+ config.setValue(nextSize);
2224
+ setStatus(config.label + ": " + formatStudioFontSizeLabel(nextSize) + ".");
2225
+ }
2226
+
2227
+ function resetActivePaneTextSize() {
2228
+ const config = getActivePaneTextSizeConfig();
2229
+ config.setValue(config.defaultValue);
2230
+ setStatus(config.label + " reset to " + formatStudioFontSizeLabel(config.defaultValue) + ".");
2231
+ }
2232
+
2177
2233
  function getStudioUiRefreshAnnotationHeaderEnabled() {
2178
2234
  try {
2179
2235
  return Boolean(stripAnnotationHeader(sourceTextEl.value).hadHeader);
@@ -3648,6 +3704,24 @@
3648
3704
  }
3649
3705
  }
3650
3706
 
3707
+ if (!isTextEntryShortcutTarget(event.target) && !event.metaKey && !event.ctrlKey && event.altKey) {
3708
+ if (code === "Equal" || code === "NumpadAdd" || key === "=" || key === "+") {
3709
+ event.preventDefault();
3710
+ adjustActivePaneTextSize(1);
3711
+ return;
3712
+ }
3713
+ if (code === "Minus" || code === "NumpadSubtract" || key === "-" || key === "_") {
3714
+ event.preventDefault();
3715
+ adjustActivePaneTextSize(-1);
3716
+ return;
3717
+ }
3718
+ if (code === "Digit0" || code === "Numpad0" || key === "0") {
3719
+ event.preventDefault();
3720
+ resetActivePaneTextSize();
3721
+ return;
3722
+ }
3723
+ }
3724
+
3651
3725
  const isPaneSwitchShortcut = key === "F6" && !event.metaKey && !event.ctrlKey && !event.altKey;
3652
3726
  if (isPaneSwitchShortcut) {
3653
3727
  event.preventDefault();
@@ -4024,6 +4098,12 @@
4024
4098
  }
4025
4099
  if (referenceMetaEl instanceof HTMLElement) referenceMetaEl.hidden = false;
4026
4100
 
4101
+ if (rightView === "files") {
4102
+ const dir = fileBrowserState && fileBrowserState.currentDir ? fileBrowserState.currentDir : (getCurrentResourceDirValue() || "current Studio directory");
4103
+ referenceBadgeEl.textContent = "Files: " + dir;
4104
+ return;
4105
+ }
4106
+
4027
4107
  if (rightView === "trace") {
4028
4108
  const state = traceState || createEmptyTraceState();
4029
4109
  const context = traceDisplayContext || {};
@@ -6584,6 +6664,7 @@
6584
6664
  critiqueViewEl.addEventListener("scroll", handleTracePaneScroll);
6585
6665
  critiqueViewEl.addEventListener("click", handleTracePaneClick);
6586
6666
  critiqueViewEl.addEventListener("click", handleReplPaneClick);
6667
+ critiqueViewEl.addEventListener("click", handleFilesPaneClick);
6587
6668
  critiqueViewEl.addEventListener("change", handleReplPaneChange);
6588
6669
  }
6589
6670
 
@@ -8046,6 +8127,241 @@
8046
8127
  scheduleResponsePaneRepaintNudge();
8047
8128
  }
8048
8129
 
8130
+ function getFileBrowserContextKey() {
8131
+ const context = getHtmlPreviewResourceContextOptions();
8132
+ return String(context.sourcePath || "") + "\n" + String(context.resourceDir || "");
8133
+ }
8134
+
8135
+ function getFileBrowserLocalLinkContext() {
8136
+ return { sourcePath: "", resourceDir: fileBrowserState.rootDir || getCurrentResourceDirValue() || "" };
8137
+ }
8138
+
8139
+ function formatFileBrowserSize(size) {
8140
+ const value = Number(size);
8141
+ if (!Number.isFinite(value) || value < 0) return "";
8142
+ if (value < 1024) return Math.round(value) + " B";
8143
+ if (value < 1024 * 1024) return (value / 1024).toFixed(value < 10 * 1024 ? 1 : 0) + " KB";
8144
+ if (value < 1024 * 1024 * 1024) return (value / (1024 * 1024)).toFixed(value < 10 * 1024 * 1024 ? 1 : 0) + " MB";
8145
+ return (value / (1024 * 1024 * 1024)).toFixed(1) + " GB";
8146
+ }
8147
+
8148
+ function formatFileBrowserTime(ms) {
8149
+ const value = Number(ms);
8150
+ if (!Number.isFinite(value) || value <= 0) return "";
8151
+ try {
8152
+ return new Date(value).toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" });
8153
+ } catch {
8154
+ return "";
8155
+ }
8156
+ }
8157
+
8158
+ function getFileBrowserKindLabel(entry) {
8159
+ if (!entry || entry.type === "directory") return "folder";
8160
+ if (entry.kind === "text") return "document";
8161
+ if (entry.kind === "pdf") return "PDF";
8162
+ if (entry.kind === "image") return "image";
8163
+ return entry.extension ? entry.extension.replace(/^\./, "") : "file";
8164
+ }
8165
+
8166
+ function buildFileBrowserPanelHtml() {
8167
+ const state = fileBrowserState || {};
8168
+ const entries = Array.isArray(state.entries) ? state.entries : [];
8169
+ const currentDir = state.currentDir || "";
8170
+ const rootDir = state.rootDir || "";
8171
+ const relativeDir = state.relativeDir || ".";
8172
+ const parentDisabled = state.parentDir ? "" : " disabled";
8173
+ const rows = entries.length
8174
+ ? entries.map((entry) => {
8175
+ const type = entry.type === "directory" ? "directory" : "file";
8176
+ const kind = entry.kind || (type === "directory" ? "directory" : "other");
8177
+ const icon = type === "directory" ? "📁" : (kind === "pdf" ? "📄" : (kind === "image" ? "🖼️" : (kind === "text" ? "📝" : "📦")));
8178
+ const metaParts = [];
8179
+ metaParts.push(getFileBrowserKindLabel(entry));
8180
+ if (type === "file") metaParts.push(formatFileBrowserSize(entry.size));
8181
+ const time = formatFileBrowserTime(entry.mtimeMs);
8182
+ if (time) metaParts.push(time);
8183
+ const textActions = kind === "text"
8184
+ ? "<button type='button' data-files-action='open-new' data-files-path='" + escapeHtml(entry.path) + "'>New tab</button>"
8185
+ : "";
8186
+ const openTitle = type === "directory"
8187
+ ? "Open folder"
8188
+ : (kind === "text" ? "Open in editor" : (kind === "pdf" ? "Open PDF preview" : (kind === "image" ? "Open image preview" : "Copy or reveal this file")));
8189
+ return "<div class='files-row files-row-" + escapeHtml(type) + " files-kind-" + escapeHtml(kind) + "'>"
8190
+ + "<button type='button' class='files-open-btn' data-files-action='" + (type === "directory" ? "open-dir" : "open") + "' data-files-path='" + escapeHtml(entry.path) + "' data-files-kind='" + escapeHtml(kind) + "' title='" + escapeHtml(openTitle) + "'>"
8191
+ + "<span class='files-icon' aria-hidden='true'>" + icon + "</span>"
8192
+ + "<span class='files-name'>" + escapeHtml(entry.name) + "</span>"
8193
+ + "<span class='files-meta'>" + escapeHtml(metaParts.filter(Boolean).join(" · ")) + "</span>"
8194
+ + "</button>"
8195
+ + "<span class='files-actions'>"
8196
+ + textActions
8197
+ + "<button type='button' data-files-action='copy-path' data-files-path='" + escapeHtml(entry.path) + "'>Copy path</button>"
8198
+ + (type === "file" ? "<button type='button' data-files-action='reveal' data-files-path='" + escapeHtml(entry.path) + "'>Reveal</button>" : "")
8199
+ + "</span>"
8200
+ + "</div>";
8201
+ }).join("")
8202
+ : "<div class='files-empty'>" + (state.loading ? "Loading files…" : "This folder is empty.") + "</div>";
8203
+ const notices = [];
8204
+ if (state.error) notices.push("<div class='files-notice files-notice-error'>" + escapeHtml(state.error) + "</div>");
8205
+ if (state.omitted) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omitted)) + " item" + (state.omitted === 1 ? "" : "s") + " omitted.</div>");
8206
+ if (state.omittedIgnored) notices.push("<div class='files-notice'>" + escapeHtml(String(state.omittedIgnored)) + " heavy/cache folder" + (state.omittedIgnored === 1 ? "" : "s") + " hidden.</div>");
8207
+ return "<div class='files-panel'>"
8208
+ + "<div class='files-toolbar'>"
8209
+ + "<div class='files-path-group'><span class='files-label'>Files</span><span class='files-path' title='" + escapeHtml(currentDir) + "'>" + escapeHtml(relativeDir || ".") + "</span></div>"
8210
+ + "<div class='files-toolbar-actions'>"
8211
+ + "<button type='button' data-files-action='parent'" + parentDisabled + ">Parent</button>"
8212
+ + "<button type='button' data-files-action='refresh'>Refresh</button>"
8213
+ + (rootDir ? "<button type='button' data-files-action='copy-root' data-files-path='" + escapeHtml(rootDir) + "'>Copy root</button>" : "")
8214
+ + "</div>"
8215
+ + "</div>"
8216
+ + "<div class='files-subtitle'>Root: <span title='" + escapeHtml(rootDir) + "'>" + escapeHtml(rootDir || "current Studio directory") + "</span></div>"
8217
+ + notices.join("")
8218
+ + "<div class='files-list' role='list'>" + rows + "</div>"
8219
+ + "</div>";
8220
+ }
8221
+
8222
+ function renderFilesView() {
8223
+ if (!critiqueViewEl) return;
8224
+ const contextKey = getFileBrowserContextKey();
8225
+ if (fileBrowserState.contextKey !== contextKey) {
8226
+ fileBrowserState = {
8227
+ rootDir: "",
8228
+ currentDir: "",
8229
+ relativeDir: "",
8230
+ parentDir: null,
8231
+ entries: [],
8232
+ omitted: 0,
8233
+ omittedIgnored: 0,
8234
+ loading: false,
8235
+ error: "",
8236
+ loaded: false,
8237
+ contextKey,
8238
+ };
8239
+ }
8240
+ finishPreviewRender(critiqueViewEl);
8241
+ critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
8242
+ critiqueViewEl.classList.remove("response-scroll-resetting");
8243
+ if (!fileBrowserState.loaded && !fileBrowserState.loading) {
8244
+ loadFileBrowserDirectory("");
8245
+ }
8246
+ scheduleResponsePaneRepaintNudge();
8247
+ }
8248
+
8249
+ async function loadFileBrowserDirectory(dir, options) {
8250
+ const context = getHtmlPreviewResourceContextOptions();
8251
+ const contextKey = getFileBrowserContextKey();
8252
+ const nonce = ++fileBrowserLoadNonce;
8253
+ fileBrowserState = {
8254
+ ...fileBrowserState,
8255
+ contextKey,
8256
+ loading: true,
8257
+ error: "",
8258
+ };
8259
+ if (rightView === "files") {
8260
+ finishPreviewRender(critiqueViewEl);
8261
+ critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
8262
+ }
8263
+ try {
8264
+ const query = {};
8265
+ if (dir) query.dir = String(dir);
8266
+ if (context.sourcePath) query.sourcePath = context.sourcePath;
8267
+ if (context.resourceDir) query.resourceDir = context.resourceDir;
8268
+ const payload = await fetchStudioJson("/file-browser", { query });
8269
+ if (nonce !== fileBrowserLoadNonce) return;
8270
+ fileBrowserState = {
8271
+ rootDir: typeof payload.rootDir === "string" ? payload.rootDir : "",
8272
+ currentDir: typeof payload.currentDir === "string" ? payload.currentDir : "",
8273
+ relativeDir: typeof payload.relativeDir === "string" ? payload.relativeDir : ".",
8274
+ parentDir: typeof payload.parentDir === "string" ? payload.parentDir : null,
8275
+ entries: Array.isArray(payload.entries) ? payload.entries : [],
8276
+ omitted: Number(payload.omitted) || 0,
8277
+ omittedIgnored: Number(payload.omittedIgnored) || 0,
8278
+ loading: false,
8279
+ error: "",
8280
+ loaded: true,
8281
+ contextKey,
8282
+ };
8283
+ if (rightView === "files") {
8284
+ finishPreviewRender(critiqueViewEl);
8285
+ critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
8286
+ scheduleResponsePaneRepaintNudge();
8287
+ }
8288
+ if (options && options.user) setStatus("Loaded file list.", "success");
8289
+ } catch (error) {
8290
+ if (nonce !== fileBrowserLoadNonce) return;
8291
+ fileBrowserState = {
8292
+ ...fileBrowserState,
8293
+ loading: false,
8294
+ error: (error && error.message) ? error.message : String(error || "Could not load files."),
8295
+ loaded: true,
8296
+ };
8297
+ if (rightView === "files") {
8298
+ finishPreviewRender(critiqueViewEl);
8299
+ critiqueViewEl.innerHTML = buildFileBrowserPanelHtml();
8300
+ scheduleResponsePaneRepaintNudge();
8301
+ }
8302
+ }
8303
+ }
8304
+
8305
+ async function openFileBrowserEntry(path, kind) {
8306
+ const context = getFileBrowserLocalLinkContext();
8307
+ if (kind === "text") {
8308
+ await openPreviewDocumentHere(path, context);
8309
+ return;
8310
+ }
8311
+ if (kind === "pdf") {
8312
+ openPreviewPdfLink(path, path, context);
8313
+ return;
8314
+ }
8315
+ if (kind === "image") {
8316
+ await openPreviewImageLink(path, path, context);
8317
+ return;
8318
+ }
8319
+ setStatus("No Studio preview for this file type. Use Copy path or Reveal.", "warning");
8320
+ }
8321
+
8322
+ async function handleFilesPaneClick(event) {
8323
+ if (rightView !== "files") return;
8324
+ const target = event.target;
8325
+ const actionEl = target instanceof Element ? target.closest("[data-files-action]") : null;
8326
+ if (!actionEl) return;
8327
+ event.preventDefault();
8328
+ const action = actionEl.getAttribute("data-files-action") || "";
8329
+ const path = actionEl.getAttribute("data-files-path") || "";
8330
+ const kind = actionEl.getAttribute("data-files-kind") || getPreviewLocalLinkKind(path);
8331
+ try {
8332
+ if (action === "parent") {
8333
+ if (fileBrowserState.parentDir) await loadFileBrowserDirectory(fileBrowserState.parentDir, { user: true });
8334
+ return;
8335
+ }
8336
+ if (action === "refresh") {
8337
+ await loadFileBrowserDirectory(fileBrowserState.currentDir || "", { user: true });
8338
+ return;
8339
+ }
8340
+ if (action === "open-dir") {
8341
+ await loadFileBrowserDirectory(path, { user: true });
8342
+ return;
8343
+ }
8344
+ if (action === "open") {
8345
+ await openFileBrowserEntry(path, kind);
8346
+ return;
8347
+ }
8348
+ if (action === "open-new") {
8349
+ await openPreviewDocumentInNewEditor(path, null, getFileBrowserLocalLinkContext());
8350
+ return;
8351
+ }
8352
+ if (action === "copy-path" || action === "copy-root") {
8353
+ const ok = await writeTextToClipboard(path);
8354
+ setStatus(ok ? "Copied path." : "Clipboard write failed.", ok ? "success" : "warning");
8355
+ return;
8356
+ }
8357
+ if (action === "reveal") {
8358
+ await revealPreviewLocalLink(path, getFileBrowserLocalLinkContext());
8359
+ }
8360
+ } catch (error) {
8361
+ setStatus((error && error.message) ? error.message : String(error || "File action failed."), "warning");
8362
+ }
8363
+ }
8364
+
8049
8365
  function renderActiveResult() {
8050
8366
  if (rightView === "trace") {
8051
8367
  renderTraceView();
@@ -8057,6 +8373,11 @@
8057
8373
  return;
8058
8374
  }
8059
8375
 
8376
+ if (rightView === "files") {
8377
+ renderFilesView();
8378
+ return;
8379
+ }
8380
+
8060
8381
  if (rightView === "editor-preview") {
8061
8382
  const editorText = prepareEditorTextForPreview(sourceTextEl.value || "");
8062
8383
  if (!editorText.trim()) {
@@ -8131,7 +8452,7 @@
8131
8452
  : normalizeForCompare(sourceTextEl.value);
8132
8453
  const responseLoaded = hasResponse && normalizedEditor === latestResponseNormalized;
8133
8454
  const isCritiqueResponse = hasResponse && latestResponseIsStructuredCritique;
8134
- const showingAuxiliaryRightPane = rightView === "trace" || rightView === "repl";
8455
+ const showingAuxiliaryRightPane = rightView === "trace" || rightView === "repl" || rightView === "files";
8135
8456
 
8136
8457
  if (responseWrapEl) {
8137
8458
  responseWrapEl.hidden = showingAuxiliaryRightPane;
@@ -8172,6 +8493,8 @@
8172
8493
  : (exportingReplJournal ? "Export record" : "Export right preview");
8173
8494
  if (rightView === "trace") {
8174
8495
  exportPdfBtn.title = "Working view does not support preview export.";
8496
+ } else if (rightView === "files") {
8497
+ exportPdfBtn.title = "Files view does not support preview export.";
8175
8498
  } else if (exportingReplJournal && !replJournalExportEntries.length) {
8176
8499
  exportPdfBtn.title = "No Studio REPL record entries to export for this session yet.";
8177
8500
  } else if (rightView === "markdown") {
@@ -8404,9 +8727,24 @@
8404
8727
  return "source:" + normalized.source + ":" + normalized.label;
8405
8728
  }
8406
8729
 
8730
+ function getWorkspacePersistenceStorage() {
8731
+ try {
8732
+ return window.sessionStorage || null;
8733
+ } catch {
8734
+ return null;
8735
+ }
8736
+ }
8737
+
8738
+ function clearLegacyWorkspacePersistenceStorage() {
8739
+ try {
8740
+ if (window.localStorage) window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8741
+ } catch {}
8742
+ }
8743
+
8407
8744
  function readPersistedWorkspaceState() {
8408
8745
  try {
8409
- const raw = window.localStorage ? window.localStorage.getItem(STUDIO_WORKSPACE_STORAGE_KEY) : null;
8746
+ const storage = getWorkspacePersistenceStorage();
8747
+ const raw = storage ? storage.getItem(STUDIO_WORKSPACE_STORAGE_KEY) : null;
8410
8748
  if (!raw) return null;
8411
8749
  const parsed = JSON.parse(raw);
8412
8750
  if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return null;
@@ -8434,7 +8772,7 @@
8434
8772
  sourceState: normalizeWorkspaceSourceState(sourceState),
8435
8773
  resourceDir: getCurrentResourceDirValue(),
8436
8774
  editorView,
8437
- rightView,
8775
+ rightView: isEditorOnlyMode ? "editor-preview" : rightView,
8438
8776
  editorLanguage,
8439
8777
  followLatest,
8440
8778
  responseHistoryIndex,
@@ -8448,13 +8786,15 @@
8448
8786
  function persistWorkspaceStateNow() {
8449
8787
  if (!workspacePersistenceReady) return;
8450
8788
  try {
8451
- if (!window.localStorage) return;
8789
+ const storage = getWorkspacePersistenceStorage();
8790
+ if (!storage) return;
8791
+ clearLegacyWorkspacePersistenceStorage();
8452
8792
  const payload = buildWorkspacePersistencePayload();
8453
8793
  if (payload.text.length > STUDIO_WORKSPACE_MAX_TEXT_CHARS) {
8454
- window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8794
+ storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8455
8795
  return;
8456
8796
  }
8457
- window.localStorage.setItem(STUDIO_WORKSPACE_STORAGE_KEY, JSON.stringify(payload));
8797
+ storage.setItem(STUDIO_WORKSPACE_STORAGE_KEY, JSON.stringify(payload));
8458
8798
  } catch {
8459
8799
  // Ignore browser storage failures and quota limits.
8460
8800
  }
@@ -8483,8 +8823,10 @@
8483
8823
  workspacePersistTimer = null;
8484
8824
  }
8485
8825
  try {
8486
- if (window.localStorage) window.localStorage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8826
+ const storage = getWorkspacePersistenceStorage();
8827
+ if (storage) storage.removeItem(STUDIO_WORKSPACE_STORAGE_KEY);
8487
8828
  } catch {}
8829
+ clearLegacyWorkspacePersistenceStorage();
8488
8830
  }
8489
8831
 
8490
8832
  function applyPersistedWorkspaceState(state) {
@@ -8502,11 +8844,15 @@
8502
8844
  setEditorLanguage(state.editorLanguage.trim());
8503
8845
  }
8504
8846
  editorView = state.editorView === "preview" ? "preview" : "markdown";
8505
- rightView = state.rightView === "preview"
8506
- ? "preview"
8507
- : (state.rightView === "editor-preview"
8508
- ? "editor-preview"
8509
- : (state.rightView === "repl" ? "repl" : ((state.rightView === "trace" || state.rightView === "thinking") ? "trace" : "markdown")));
8847
+ rightView = isEditorOnlyMode
8848
+ ? "editor-preview"
8849
+ : (state.rightView === "preview"
8850
+ ? "preview"
8851
+ : (state.rightView === "editor-preview"
8852
+ ? "editor-preview"
8853
+ : (state.rightView === "repl"
8854
+ ? "repl"
8855
+ : (state.rightView === "files" ? "files" : ((state.rightView === "trace" || state.rightView === "thinking") ? "trace" : "markdown")))));
8510
8856
  if (typeof state.followLatest === "boolean") {
8511
8857
  followLatest = state.followLatest;
8512
8858
  }
@@ -8531,7 +8877,7 @@
8531
8877
  setStatus("Studio is busy.", "warning");
8532
8878
  return;
8533
8879
  }
8534
- const confirmed = window.confirm("Clear the current editor draft in this browser tab? Saved files and responses are not changed.");
8880
+ const confirmed = window.confirm("Reset the editor to a fresh blank draft in this browser tab? Saved files and responses are not changed.");
8535
8881
  if (!confirmed) return;
8536
8882
  const preservedResponseState = {
8537
8883
  responseHistory: Array.isArray(responseHistory) ? responseHistory.slice() : [],
@@ -8573,7 +8919,7 @@
8573
8919
  if (followSelect) followSelect.value = followLatest ? "on" : "off";
8574
8920
  refreshResponseUi();
8575
8921
  persistWorkspaceStateNow();
8576
- setStatus("Editor cleared. Saved files and responses were not changed.", "success");
8922
+ setStatus("Editor reset to a fresh blank draft. Saved files and responses were not changed.", "success");
8577
8923
  }
8578
8924
 
8579
8925
  function setEditorText(nextText, options) {
@@ -8772,7 +9118,9 @@
8772
9118
  ? "editor-preview"
8773
9119
  : (nextView === "repl"
8774
9120
  ? "repl"
8775
- : ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown")));
9121
+ : (nextView === "files"
9122
+ ? "files"
9123
+ : ((nextView === "trace" || nextView === "thinking") ? "trace" : "markdown"))));
8776
9124
  rightViewSelect.value = rightView;
8777
9125
  if (rightView === "trace" && previousView !== "trace") {
8778
9126
  traceAutoScroll = true;
@@ -9112,6 +9460,11 @@
9112
9460
  ".diff", ".patch",
9113
9461
  ]);
9114
9462
  const PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
9463
+ const PREVIEW_LOCAL_TEXT_LINK_FILENAMES = new Set([
9464
+ ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
9465
+ ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
9466
+ "justfile", "license", "makefile", "rakefile", "readme",
9467
+ ]);
9115
9468
  let previewLinkMenuEl = null;
9116
9469
  let activePreviewLinkContext = null;
9117
9470
 
@@ -9159,10 +9512,17 @@
9159
9512
  return match ? ("." + match[1].toLowerCase()) : "";
9160
9513
  }
9161
9514
 
9515
+ function getPreviewLocalLinkFilename(href) {
9516
+ const path = stripPreviewLocalLinkUrlSuffix(href).replace(/\\/g, "/");
9517
+ const parts = path.split("/");
9518
+ return (parts.pop() || "").toLowerCase();
9519
+ }
9520
+
9162
9521
  function getPreviewLocalLinkKind(href) {
9163
9522
  const ext = getPreviewLocalLinkExtension(href);
9523
+ const name = getPreviewLocalLinkFilename(href);
9164
9524
  if (ext === ".pdf") return "pdf";
9165
- if (PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS.has(ext)) return "text";
9525
+ if (PREVIEW_LOCAL_TEXT_LINK_EXTENSIONS.has(ext) || PREVIEW_LOCAL_TEXT_LINK_FILENAMES.has(name)) return "text";
9166
9526
  if (PREVIEW_LOCAL_IMAGE_LINK_EXTENSIONS.has(ext)) return "image";
9167
9527
  return "other";
9168
9528
  }
@@ -9179,9 +9539,11 @@
9179
9539
  function getEffectivePreviewLinkContext(contextOverride) {
9180
9540
  const fallback = getHtmlPreviewResourceContextOptions();
9181
9541
  const context = contextOverride && typeof contextOverride === "object" ? contextOverride : null;
9542
+ const hasSourcePath = Boolean(context && Object.prototype.hasOwnProperty.call(context, "sourcePath"));
9543
+ const hasResourceDir = Boolean(context && Object.prototype.hasOwnProperty.call(context, "resourceDir"));
9182
9544
  return {
9183
- sourcePath: context && context.sourcePath ? String(context.sourcePath) : (fallback.sourcePath || ""),
9184
- resourceDir: context && context.resourceDir ? String(context.resourceDir) : (fallback.resourceDir || ""),
9545
+ sourcePath: hasSourcePath ? String(context.sourcePath || "") : (fallback.sourcePath || ""),
9546
+ resourceDir: hasResourceDir ? String(context.resourceDir || "") : (fallback.resourceDir || ""),
9185
9547
  };
9186
9548
  }
9187
9549
 
@@ -17830,7 +18192,7 @@
17830
18192
  renderSourcePreview();
17831
18193
  workspacePersistenceReady = true;
17832
18194
  if (workspaceRestoredFromBrowser) {
17833
- setStatus("Restored editor workspace from this browser tab. Use Clear editor to discard it.", "success");
18195
+ setStatus("Restored editor workspace from this browser tab. Use Reset editor to discard it.", "success");
17834
18196
  }
17835
18197
  connect();
17836
18198
  } catch (error) {
package/client/studio.css CHANGED
@@ -641,6 +641,7 @@
641
641
  overflow: hidden;
642
642
  text-overflow: ellipsis;
643
643
  white-space: nowrap;
644
+ font-weight: 400;
644
645
  }
645
646
  .resource-dir-label:hover {
646
647
  color: var(--fg);
@@ -2665,6 +2666,148 @@
2665
2666
  flex: 0 0 auto;
2666
2667
  }
2667
2668
 
2669
+ .files-panel {
2670
+ display: flex;
2671
+ flex-direction: column;
2672
+ gap: 10px;
2673
+ color: var(--text);
2674
+ font-family: var(--font-ui);
2675
+ font-size: var(--studio-response-font-size);
2676
+ }
2677
+
2678
+ .files-toolbar {
2679
+ display: flex;
2680
+ align-items: center;
2681
+ justify-content: space-between;
2682
+ gap: 10px;
2683
+ flex-wrap: wrap;
2684
+ padding: 10px;
2685
+ border: 1px solid var(--border-subtle);
2686
+ border-radius: 10px;
2687
+ background: var(--panel-2);
2688
+ }
2689
+
2690
+ .files-path-group {
2691
+ min-width: 0;
2692
+ display: flex;
2693
+ align-items: baseline;
2694
+ gap: 8px;
2695
+ }
2696
+
2697
+ .files-label {
2698
+ flex: 0 0 auto;
2699
+ font-size: 11px;
2700
+ font-weight: 700;
2701
+ letter-spacing: 0.04em;
2702
+ text-transform: uppercase;
2703
+ color: var(--studio-info-text, var(--muted));
2704
+ }
2705
+
2706
+ .files-path,
2707
+ .files-subtitle span {
2708
+ min-width: 0;
2709
+ overflow: hidden;
2710
+ text-overflow: ellipsis;
2711
+ white-space: nowrap;
2712
+ font-family: var(--font-mono);
2713
+ }
2714
+
2715
+ .files-toolbar-actions,
2716
+ .files-actions {
2717
+ display: inline-flex;
2718
+ align-items: center;
2719
+ gap: 6px;
2720
+ flex-wrap: wrap;
2721
+ }
2722
+
2723
+ .files-toolbar-actions button,
2724
+ .files-actions button {
2725
+ padding: 4px 7px;
2726
+ font-size: 11px;
2727
+ }
2728
+
2729
+ .files-subtitle,
2730
+ .files-notice,
2731
+ .files-empty {
2732
+ color: var(--studio-info-text, var(--muted));
2733
+ font-size: 12px;
2734
+ }
2735
+
2736
+ .files-notice-error {
2737
+ color: var(--danger);
2738
+ }
2739
+
2740
+ .files-list {
2741
+ display: grid;
2742
+ gap: 4px;
2743
+ }
2744
+
2745
+ .files-row {
2746
+ display: grid;
2747
+ grid-template-columns: minmax(0, 1fr) auto;
2748
+ align-items: center;
2749
+ gap: 8px;
2750
+ padding: 5px 7px;
2751
+ border: 1px solid var(--border-subtle);
2752
+ border-radius: 9px;
2753
+ background: var(--panel);
2754
+ }
2755
+
2756
+ .files-row:hover {
2757
+ border-color: var(--control-border);
2758
+ background: var(--panel-2);
2759
+ }
2760
+
2761
+ .files-open-btn {
2762
+ display: grid;
2763
+ grid-template-columns: auto minmax(0, 1fr) auto;
2764
+ align-items: center;
2765
+ gap: 8px;
2766
+ min-width: 0;
2767
+ padding: 3px 0;
2768
+ border: 0;
2769
+ background: transparent;
2770
+ color: var(--text);
2771
+ text-align: left;
2772
+ cursor: pointer;
2773
+ }
2774
+
2775
+ .files-open-btn:hover,
2776
+ .files-open-btn:focus-visible {
2777
+ outline: none;
2778
+ color: var(--accent);
2779
+ }
2780
+
2781
+ .files-icon {
2782
+ width: 1.3em;
2783
+ text-align: center;
2784
+ }
2785
+
2786
+ .files-name {
2787
+ min-width: 0;
2788
+ overflow: hidden;
2789
+ text-overflow: ellipsis;
2790
+ white-space: nowrap;
2791
+ font-weight: 550;
2792
+ }
2793
+
2794
+ .files-meta {
2795
+ color: var(--studio-info-text, var(--muted));
2796
+ font-size: 11px;
2797
+ white-space: nowrap;
2798
+ }
2799
+
2800
+ @container (max-width: 620px) {
2801
+ .files-row,
2802
+ .files-open-btn {
2803
+ grid-template-columns: minmax(0, 1fr);
2804
+ }
2805
+ .files-actions,
2806
+ .files-meta {
2807
+ justify-self: start;
2808
+ }
2809
+ }
2810
+
2668
2811
  .trace-panel {
2669
2812
  display: flex;
2670
2813
  flex-direction: column;
@@ -3583,17 +3726,29 @@
3583
3726
 
3584
3727
  .shortcuts-body {
3585
3728
  display: grid;
3586
- gap: 12px;
3587
- padding: 14px 18px 16px;
3729
+ gap: 16px;
3730
+ padding: 16px 18px 18px;
3588
3731
  overflow-y: auto;
3589
3732
  overflow-x: hidden;
3590
3733
  min-height: 0;
3591
3734
  background: var(--panel);
3592
3735
  }
3593
3736
 
3737
+ .shortcuts-group {
3738
+ display: block;
3739
+ min-height: auto;
3740
+ border: 0;
3741
+ border-radius: 0;
3742
+ background: transparent;
3743
+ box-shadow: none;
3744
+ overflow: visible;
3745
+ }
3746
+
3594
3747
  .shortcuts-group h3 {
3595
- margin: 0 0 6px;
3748
+ margin: 0 0 8px;
3749
+ padding-top: 3px;
3596
3750
  font-size: 12px;
3751
+ line-height: 1.5;
3597
3752
  font-weight: 700;
3598
3753
  color: var(--studio-info-text, var(--muted));
3599
3754
  text-transform: uppercase;
@@ -4259,7 +4414,6 @@
4259
4414
  }
4260
4415
 
4261
4416
  body.studio-ui-refresh #resourceDirBtn,
4262
- body.studio-ui-refresh #resourceDirLabel,
4263
4417
  body.studio-ui-refresh #reviewNotesBtn,
4264
4418
  body.studio-ui-refresh #outlineBtn,
4265
4419
  body.studio-ui-refresh #scratchpadBtn,
@@ -4267,6 +4421,11 @@
4267
4421
  color: var(--text);
4268
4422
  }
4269
4423
 
4424
+ body.studio-ui-refresh #resourceDirLabel {
4425
+ color: var(--studio-info-text, var(--muted));
4426
+ font-weight: 400;
4427
+ }
4428
+
4270
4429
  body.studio-ui-refresh #resourceDirInputWrap.visible {
4271
4430
  display: inline-flex;
4272
4431
  }
package/index.ts CHANGED
@@ -2442,6 +2442,16 @@ const STUDIO_LOCAL_LINK_TEXT_EXTENSIONS = new Set([
2442
2442
  ".diff", ".patch",
2443
2443
  ]);
2444
2444
  const STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
2445
+ const STUDIO_LOCAL_LINK_TEXT_FILENAMES = new Set([
2446
+ ".dockerignore", ".editorconfig", ".env", ".env.example", ".eslintignore", ".gitattributes",
2447
+ ".gitignore", ".gitmodules", ".npmignore", ".prettierignore", "dockerfile", "gemfile",
2448
+ "justfile", "license", "makefile", "rakefile", "readme",
2449
+ ]);
2450
+ const STUDIO_FILE_BROWSER_MAX_ENTRIES = 500;
2451
+ const STUDIO_FILE_BROWSER_IGNORED_DIRS = new Set([
2452
+ ".git", "node_modules", ".next", ".cache", "dist", "build", "coverage", "target",
2453
+ "__pycache__", ".venv", "venv", ".mypy_cache", ".pytest_cache", ".ruff_cache",
2454
+ ]);
2445
2455
 
2446
2456
  type StudioLocalPreviewResourceKind = "pdf" | "text" | "image" | "other";
2447
2457
 
@@ -2454,6 +2464,17 @@ interface StudioLocalPreviewResource {
2454
2464
  resourceDir: string;
2455
2465
  }
2456
2466
 
2467
+ interface StudioFileBrowserEntry {
2468
+ name: string;
2469
+ path: string;
2470
+ type: "directory" | "file";
2471
+ extension: string;
2472
+ kind: StudioLocalPreviewResourceKind | "directory";
2473
+ size: number;
2474
+ mtimeMs: number;
2475
+ hidden: boolean;
2476
+ }
2477
+
2457
2478
  function resolveStudioPdfResourcePath(pdfPath: string | undefined, sourcePath: string | undefined, resourceDir: string | undefined, fallbackCwd: string): string {
2458
2479
  const rawPath = typeof pdfPath === "string" ? pdfPath.trim() : "";
2459
2480
  if (!rawPath) throw new Error("Missing PDF path.");
@@ -2522,10 +2543,11 @@ function parseStudioLocalPreviewResourcePage(resourcePath: string): number | nul
2522
2543
  return null;
2523
2544
  }
2524
2545
 
2525
- function getStudioLocalPreviewResourceKind(extension: string): StudioLocalPreviewResourceKind {
2546
+ function getStudioLocalPreviewResourceKind(extension: string, filePathOrName?: string): StudioLocalPreviewResourceKind {
2526
2547
  const ext = extension.toLowerCase();
2548
+ const name = basename(String(filePathOrName || "")).toLowerCase();
2527
2549
  if (ext === ".pdf") return "pdf";
2528
- if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext)) return "text";
2550
+ if (STUDIO_LOCAL_LINK_TEXT_EXTENSIONS.has(ext) || STUDIO_LOCAL_LINK_TEXT_FILENAMES.has(name)) return "text";
2529
2551
  if (STUDIO_LOCAL_LINK_IMAGE_EXTENSIONS.has(ext)) return "image";
2530
2552
  return "other";
2531
2553
  }
@@ -2563,12 +2585,94 @@ function resolveStudioLocalPreviewResourcePath(
2563
2585
  filePath: candidateReal,
2564
2586
  label: rel && rel !== "" ? rel : basename(candidateReal),
2565
2587
  extension,
2566
- kind: getStudioLocalPreviewResourceKind(extension),
2588
+ kind: getStudioLocalPreviewResourceKind(extension, candidateReal),
2567
2589
  page: parseStudioLocalPreviewResourcePage(rawPath),
2568
2590
  resourceDir: boundaryReal,
2569
2591
  };
2570
2592
  }
2571
2593
 
2594
+ function resolveStudioFileBrowserDirectory(
2595
+ dirPath: string | undefined,
2596
+ sourcePath: string | undefined,
2597
+ resourceDir: string | undefined,
2598
+ fallbackCwd: string,
2599
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null } {
2600
+ const context = resolveStudioPreviewResourceContext(sourcePath, resourceDir, fallbackCwd);
2601
+ const rootReal = realpathSync(context.boundaryDir);
2602
+ const rawDir = typeof dirPath === "string" ? dirPath.trim() : "";
2603
+ const baseDir = context.baseDir;
2604
+ const requested = rawDir
2605
+ ? (isAbsolute(recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir)))
2606
+ ? recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))
2607
+ : resolve(baseDir, recoverLikelyDroppedLeadingSlashPath(expandHome(rawDir))))
2608
+ : baseDir;
2609
+ const currentReal = realpathSync(requested);
2610
+ const currentStat = statSync(currentReal);
2611
+ if (!currentStat.isDirectory()) throw new Error("File browser path does not refer to a directory.");
2612
+ if (!isPathInsideOrEqualDirectory(currentReal, rootReal)) {
2613
+ throw new Error("File browser path must stay within the current Studio resource directory.");
2614
+ }
2615
+ const parent = dirname(currentReal);
2616
+ const parentDir = parent !== currentReal && isPathInsideOrEqualDirectory(parent, rootReal) ? parent : null;
2617
+ const relativeDir = relative(rootReal, currentReal) || ".";
2618
+ return { rootDir: rootReal, currentDir: currentReal, relativeDir, parentDir };
2619
+ }
2620
+
2621
+ function listStudioFileBrowserDirectory(
2622
+ dirPath: string | undefined,
2623
+ sourcePath: string | undefined,
2624
+ resourceDir: string | undefined,
2625
+ fallbackCwd: string,
2626
+ ): { rootDir: string; currentDir: string; relativeDir: string; parentDir: string | null; entries: StudioFileBrowserEntry[]; omitted: number; omittedIgnored: number } {
2627
+ const context = resolveStudioFileBrowserDirectory(dirPath, sourcePath, resourceDir, fallbackCwd);
2628
+ const entries: StudioFileBrowserEntry[] = [];
2629
+ let omitted = 0;
2630
+ let omittedIgnored = 0;
2631
+ const dirents = readdirSync(context.currentDir, { withFileTypes: true });
2632
+ for (const dirent of dirents) {
2633
+ const name = dirent.name;
2634
+ if (STUDIO_FILE_BROWSER_IGNORED_DIRS.has(name)) {
2635
+ omittedIgnored += 1;
2636
+ continue;
2637
+ }
2638
+ const candidate = join(context.currentDir, name);
2639
+ try {
2640
+ const real = realpathSync(candidate);
2641
+ if (!isPathInsideOrEqualDirectory(real, context.rootDir)) {
2642
+ omitted += 1;
2643
+ continue;
2644
+ }
2645
+ const stat = statSync(real);
2646
+ if (!stat.isDirectory() && !stat.isFile()) {
2647
+ omitted += 1;
2648
+ continue;
2649
+ }
2650
+ const type = stat.isDirectory() ? "directory" : "file";
2651
+ const extension = type === "file" ? extname(real).toLowerCase() : "";
2652
+ entries.push({
2653
+ name,
2654
+ path: real,
2655
+ type,
2656
+ extension,
2657
+ kind: type === "directory" ? "directory" : getStudioLocalPreviewResourceKind(extension, real),
2658
+ size: stat.size,
2659
+ mtimeMs: stat.mtimeMs,
2660
+ hidden: name.startsWith("."),
2661
+ });
2662
+ } catch {
2663
+ omitted += 1;
2664
+ }
2665
+ }
2666
+ entries.sort((a, b) => {
2667
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
2668
+ if (a.hidden !== b.hidden) return a.hidden ? 1 : -1;
2669
+ return a.name.localeCompare(b.name, undefined, { sensitivity: "base", numeric: true });
2670
+ });
2671
+ const limitedEntries = entries.slice(0, STUDIO_FILE_BROWSER_MAX_ENTRIES);
2672
+ omitted += Math.max(0, entries.length - limitedEntries.length);
2673
+ return { ...context, entries: limitedEntries, omitted, omittedIgnored };
2674
+ }
2675
+
2572
2676
  function resolveStudioHtmlPreviewResourcePath(
2573
2677
  resourcePath: string | undefined,
2574
2678
  sourcePath: string | undefined,
@@ -8958,7 +9062,7 @@ function resolveRequestedStudioDocumentFromUrl(
8958
9062
  label: requestedLabel || file.label,
8959
9063
  source: "file",
8960
9064
  path: file.resolvedPath,
8961
- resourceDir: requestedResourceDir || dirname(file.resolvedPath),
9065
+ resourceDir: requestedResourceDir || fallback?.resourceDir || studioCwd,
8962
9066
  };
8963
9067
  }
8964
9068
  }
@@ -9314,7 +9418,7 @@ ${cssVarsBlock}
9314
9418
  <button id="saveAsBtn" type="button" title="Save editor content to a new file path. Cmd/Ctrl+S falls back here when no direct save path is available.">Save editor as…</button>
9315
9419
  <button id="saveOverBtn" type="button" title="Overwrite current file with editor content. Shortcut: Cmd/Ctrl+S.">Save editor</button>
9316
9420
  <button id="refreshFromDiskBtn" type="button" title="Reload the current file-backed document from disk.">Refresh from disk</button>
9317
- <button id="clearWorkspaceBtn" type="button" title="Clear the current editor draft in this browser tab. Saved files and responses are not changed.">Clear editor</button>
9421
+ <button id="clearWorkspaceBtn" type="button" title="Clear editor text and reset this tab to a fresh blank draft. Saved files and responses are not changed.">Reset editor</button>
9318
9422
  <label class="file-label" title="Load a local file into editor text.">Load file content<input id="fileInput" type="file" accept=".md,.markdown,.mdx,.qmd,.js,.mjs,.cjs,.jsx,.ts,.mts,.cts,.tsx,.py,.pyw,.sh,.bash,.zsh,.json,.jsonc,.json5,.rs,.c,.h,.cpp,.cxx,.cc,.hpp,.hxx,.jl,.f90,.f95,.f03,.f,.for,.r,.R,.m,.tex,.latex,.diff,.patch,.java,.go,.rb,.swift,.html,.htm,.css,.xml,.yaml,.yml,.toml,.lua,.txt,.rst,.adoc" /></label>
9319
9423
  <button id="loadGitDiffBtn" type="button" title="Load the current git diff from the Studio context into the editor.">Load git diff</button>
9320
9424
  <button id="getEditorBtn" type="button" title="Load the current terminal editor draft into Studio.">Load from pi editor</button>
@@ -9508,6 +9612,7 @@ ${cssVarsBlock}
9508
9612
  <option value="preview" selected>Response (Preview)</option>
9509
9613
  <option value="editor-preview">Editor (Preview)</option>
9510
9614
  <option value="trace">Working</option>
9615
+ <option value="files">Files</option>
9511
9616
  <option value="repl">REPL</option>
9512
9617
  </select>
9513
9618
  </div>
@@ -9600,6 +9705,14 @@ ${cssVarsBlock}
9600
9705
  <div><dt>?</dt><dd>Show keyboard shortcuts when not editing text</dd></div>
9601
9706
  </dl>
9602
9707
  </section>
9708
+ <section class="shortcuts-group">
9709
+ <h3>View</h3>
9710
+ <dl>
9711
+ <div><dt>Alt/Option+=</dt><dd>Increase the active pane's text size when not editing text</dd></div>
9712
+ <div><dt>Alt/Option+-</dt><dd>Decrease the active pane's text size when not editing text</dd></div>
9713
+ <div><dt>Alt/Option+0</dt><dd>Reset the active pane's text size when not editing text</dd></div>
9714
+ </dl>
9715
+ </section>
9603
9716
  <section class="shortcuts-group">
9604
9717
  <h3>Editor</h3>
9605
9718
  <dl>
@@ -12669,6 +12782,34 @@ export default function (pi: ExtensionAPI) {
12669
12782
  return;
12670
12783
  }
12671
12784
 
12785
+ if (requestUrl.pathname === "/file-browser") {
12786
+ const token = requestUrl.searchParams.get("token") ?? "";
12787
+ if (token !== serverState.token) {
12788
+ respondJson(res, 403, { ok: false, error: "Invalid or expired studio token. Re-run /studio." });
12789
+ return;
12790
+ }
12791
+
12792
+ const method = (req.method ?? "GET").toUpperCase();
12793
+ if (method !== "GET" && method !== "HEAD") {
12794
+ res.setHeader("Allow", "GET, HEAD");
12795
+ respondJson(res, 405, { ok: false, error: "Method not allowed. Use GET." });
12796
+ return;
12797
+ }
12798
+
12799
+ try {
12800
+ const listing = listStudioFileBrowserDirectory(
12801
+ requestUrl.searchParams.get("dir") ?? undefined,
12802
+ requestUrl.searchParams.get("sourcePath") ?? undefined,
12803
+ requestUrl.searchParams.get("resourceDir") ?? undefined,
12804
+ studioCwd,
12805
+ );
12806
+ respondJson(res, 200, { ok: true, ...listing, entries: method === "HEAD" ? [] : listing.entries });
12807
+ } catch (error) {
12808
+ respondJson(res, 404, { ok: false, error: `File browser unavailable: ${error instanceof Error ? error.message : String(error)}` });
12809
+ }
12810
+ return;
12811
+ }
12812
+
12672
12813
  if (requestUrl.pathname === "/local-preview-link") {
12673
12814
  const token = requestUrl.searchParams.get("token") ?? "";
12674
12815
  if (token !== serverState.token) {
@@ -13298,6 +13439,7 @@ export default function (pi: ExtensionAPI) {
13298
13439
  label: "last model response",
13299
13440
  source: "last-response",
13300
13441
  draftId: createStudioDraftId(),
13442
+ resourceDir: ctx.cwd,
13301
13443
  };
13302
13444
  }
13303
13445
  return {
@@ -13305,6 +13447,7 @@ export default function (pi: ExtensionAPI) {
13305
13447
  label: "blank",
13306
13448
  source: "blank",
13307
13449
  draftId: createStudioDraftId(),
13450
+ resourceDir: ctx.cwd,
13308
13451
  };
13309
13452
  }
13310
13453
 
@@ -13314,6 +13457,7 @@ export default function (pi: ExtensionAPI) {
13314
13457
  label: "blank",
13315
13458
  source: "blank",
13316
13459
  draftId: createStudioDraftId(),
13460
+ resourceDir: ctx.cwd,
13317
13461
  };
13318
13462
  }
13319
13463
 
@@ -13325,6 +13469,7 @@ export default function (pi: ExtensionAPI) {
13325
13469
  label: "blank",
13326
13470
  source: "blank",
13327
13471
  draftId: createStudioDraftId(),
13472
+ resourceDir: ctx.cwd,
13328
13473
  };
13329
13474
  }
13330
13475
  return {
@@ -13332,6 +13477,7 @@ export default function (pi: ExtensionAPI) {
13332
13477
  label: "last model response",
13333
13478
  source: "last-response",
13334
13479
  draftId: createStudioDraftId(),
13480
+ resourceDir: ctx.cwd,
13335
13481
  };
13336
13482
  }
13337
13483
 
@@ -13364,7 +13510,7 @@ export default function (pi: ExtensionAPI) {
13364
13510
  label: file.label,
13365
13511
  source: "file",
13366
13512
  path: file.resolvedPath,
13367
- resourceDir: dirname(file.resolvedPath),
13513
+ resourceDir: ctx.cwd,
13368
13514
  };
13369
13515
  };
13370
13516
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, active quiz, prompt/response history, live previews, and tmux-backed REPL/literate REPL workflows",
5
5
  "type": "module",
6
6
  "license": "MIT",