pi-studio 0.5.51 → 0.5.52

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,6 +4,18 @@ All notable changes to `pi-studio` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.52] — 2026-04-09
8
+
9
+ ### Added
10
+ - Studio now includes a docked **Outline** rail for the current editor text, with clickable structure entries that jump in the raw editor and, when possible, reveal the matching preview location.
11
+
12
+ ### Changed
13
+ - Outline scanning now provides initial structure support for Markdown headings, LaTeX sectioning commands and references, Python classes/functions, JavaScript/TypeScript classes and functions, Julia modules/structs/functions/macros, Bash functions, and diff file/hunk structure.
14
+ - Standalone preview-to-editor jumps now keep the raw-editor selection focused more reliably, so the left-side highlight persists more like comment-card jumps.
15
+
16
+ ### Fixed
17
+ - The **Outline** button now only shows its active styling while the outline rail is actually open.
18
+
7
19
  ## [0.5.51] — 2026-04-09
8
20
 
9
21
  ### Added
package/README.md CHANGED
@@ -18,6 +18,7 @@ Extension for [pi](https://pi.dev) that opens a local two-pane browser workspace
18
18
  - Supports one canonical full Studio view per Pi session, plus additional editor-only companion views when you just want extra editing/preview surfaces
19
19
  - Runs editor text directly, or asks for structured critique (auto/writing/code focus)
20
20
  - 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
21
+ - 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
21
22
  - 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
22
23
  - Browses response history (`Prev/Next/Last`) and loads either:
23
24
  - response text
@@ -104,6 +104,7 @@
104
104
  const leftFocusBtn = document.getElementById("leftFocusBtn");
105
105
  const rightFocusBtn = document.getElementById("rightFocusBtn");
106
106
  const reviewNotesBtn = document.getElementById("reviewNotesBtn");
107
+ const outlineBtn = document.getElementById("outlineBtn");
107
108
  const scratchpadBtn = document.getElementById("scratchpadBtn");
108
109
  const scratchpadOverlayEl = document.getElementById("scratchpadOverlay");
109
110
  const scratchpadDialogEl = document.getElementById("scratchpadDialog");
@@ -114,6 +115,13 @@
114
115
  const scratchpadClearBtn = document.getElementById("scratchpadClearBtn");
115
116
  const scratchpadCloseBtn = document.getElementById("scratchpadCloseBtn");
116
117
  const scratchpadDoneBtn = document.getElementById("scratchpadDoneBtn");
118
+ const outlineOverlayEl = document.getElementById("outlineOverlay");
119
+ const outlineDialogEl = document.getElementById("outlineDialog");
120
+ const outlineMetaEl = document.getElementById("outlineMeta");
121
+ const outlineListEl = document.getElementById("outlineList");
122
+ const outlineEmptyStateEl = document.getElementById("outlineEmptyState");
123
+ const outlineCloseBtn = document.getElementById("outlineCloseBtn");
124
+ const outlineDoneBtn = document.getElementById("outlineDoneBtn");
117
125
  const reviewNotesOverlayEl = document.getElementById("reviewNotesOverlay");
118
126
  const reviewNotesDialogEl = document.getElementById("reviewNotesDialog");
119
127
  const reviewNotesMetaEl = document.getElementById("reviewNotesMeta");
@@ -305,6 +313,8 @@
305
313
  let reviewNotesReturnFocusEl = null;
306
314
  let reviewNotesPersistTimer = null;
307
315
  let reviewNotesLoadNonce = 0;
316
+ let outlineEntries = [];
317
+ let outlineReturnFocusEl = null;
308
318
  let pendingReviewNoteFocusId = null;
309
319
  let pendingReviewNoteInlineFocusId = null;
310
320
  let activePreviewCommentSelection = null;
@@ -1130,6 +1140,12 @@
1130
1140
  && typeof reviewNotesDialogEl.contains === "function"
1131
1141
  && reviewNotesDialogEl.contains(event.target)
1132
1142
  );
1143
+ const outlineOwnsEvent = Boolean(
1144
+ outlineDialogEl
1145
+ && event.target
1146
+ && typeof outlineDialogEl.contains === "function"
1147
+ && outlineDialogEl.contains(event.target)
1148
+ );
1133
1149
 
1134
1150
  if (isScratchpadOpen() && plainEscape) {
1135
1151
  event.preventDefault();
@@ -1143,7 +1159,13 @@
1143
1159
  return;
1144
1160
  }
1145
1161
 
1146
- if (scratchpadOwnsEvent || reviewNotesOwnsEvent) {
1162
+ if (isOutlineOpen() && plainEscape) {
1163
+ event.preventDefault();
1164
+ closeOutline();
1165
+ return;
1166
+ }
1167
+
1168
+ if (scratchpadOwnsEvent || reviewNotesOwnsEvent || outlineOwnsEvent) {
1147
1169
  return;
1148
1170
  }
1149
1171
 
@@ -2991,6 +3013,7 @@
2991
3013
  scheduleEditorMetaUpdate();
2992
3014
  }
2993
3015
  updateEditorSelectionCommentUi();
3016
+ updateOutlineUi();
2994
3017
  }
2995
3018
 
2996
3019
  function setEditorView(nextView) {
@@ -3025,6 +3048,7 @@
3025
3048
  }
3026
3049
  updateReviewNotesUi();
3027
3050
  updateEditorSelectionCommentUi();
3051
+ updateOutlineUi();
3028
3052
  }
3029
3053
 
3030
3054
  function setRightView(nextView) {
@@ -4025,6 +4049,10 @@
4025
4049
  return Boolean(scratchpadOverlayEl && !scratchpadOverlayEl.hidden);
4026
4050
  }
4027
4051
 
4052
+ function isOutlineOpen() {
4053
+ return Boolean(outlineOverlayEl && !outlineOverlayEl.hidden);
4054
+ }
4055
+
4028
4056
  function isReviewNotesOpen() {
4029
4057
  return Boolean(reviewNotesOverlayEl && !reviewNotesOverlayEl.hidden);
4030
4058
  }
@@ -4198,6 +4226,400 @@
4198
4226
  };
4199
4227
  }
4200
4228
 
4229
+ function buildOutlineLineIndex(text) {
4230
+ const source = String(text || "").replace(/\r\n/g, "\n");
4231
+ const lines = source.split("\n");
4232
+ const lineOffsets = [];
4233
+ let runningOffset = 0;
4234
+ for (const line of lines) {
4235
+ lineOffsets.push(runningOffset);
4236
+ runningOffset += line.length + 1;
4237
+ }
4238
+ return { source, lines, lineOffsets };
4239
+ }
4240
+
4241
+ function makeOutlineEntry(options) {
4242
+ const entry = options && typeof options === "object" ? options : {};
4243
+ const label = typeof entry.label === "string" ? entry.label.trim() : "";
4244
+ if (!label) return null;
4245
+ const selectionStart = Math.max(0, Math.floor(Number(entry.selectionStart) || 0));
4246
+ const selectionEnd = Math.max(selectionStart, Math.floor(Number(entry.selectionEnd) || selectionStart));
4247
+ return {
4248
+ id: typeof entry.id === "string" && entry.id ? entry.id : makeRequestId(),
4249
+ kind: typeof entry.kind === "string" && entry.kind ? entry.kind : "section",
4250
+ depth: Math.max(1, Math.floor(Number(entry.depth) || 1)),
4251
+ label,
4252
+ lineStart: Math.max(1, Math.floor(Number(entry.lineStart) || 1)),
4253
+ lineEnd: Math.max(Math.max(1, Math.floor(Number(entry.lineStart) || 1)), Math.floor(Number(entry.lineEnd) || Math.max(1, Math.floor(Number(entry.lineStart) || 1)))),
4254
+ selectionStart,
4255
+ selectionEnd,
4256
+ selectedText: typeof entry.selectedText === "string" ? entry.selectedText : "",
4257
+ selectedDisplayText: typeof entry.selectedDisplayText === "string" && entry.selectedDisplayText ? entry.selectedDisplayText : label,
4258
+ };
4259
+ }
4260
+
4261
+ function getOutlineKindLabel(kind) {
4262
+ switch (String(kind || "")) {
4263
+ case "heading": return "Heading";
4264
+ case "section": return "Section";
4265
+ case "subsection": return "Subsection";
4266
+ case "subsubsection": return "Subsubsection";
4267
+ case "paragraph": return "Paragraph";
4268
+ case "subparagraph": return "Subparagraph";
4269
+ case "class": return "Class";
4270
+ case "function": return "Function";
4271
+ case "interface": return "Interface";
4272
+ case "enum": return "Enum";
4273
+ case "type": return "Type";
4274
+ case "struct": return "Struct";
4275
+ case "module": return "Module";
4276
+ case "macro": return "Macro";
4277
+ case "file": return "File";
4278
+ case "hunk": return "Hunk";
4279
+ default: return "Item";
4280
+ }
4281
+ }
4282
+
4283
+ function getOutlineKindBadge(kind) {
4284
+ switch (String(kind || "")) {
4285
+ case "section": return "§";
4286
+ case "subsection": return "§§";
4287
+ case "subsubsection": return "§3";
4288
+ case "paragraph": return "¶";
4289
+ case "subparagraph": return "¶2";
4290
+ case "class": return "class";
4291
+ case "function": return "def";
4292
+ case "interface": return "iface";
4293
+ case "enum": return "enum";
4294
+ case "type": return "type";
4295
+ case "struct": return "struct";
4296
+ case "module": return "mod";
4297
+ case "macro": return "macro";
4298
+ case "file": return "file";
4299
+ case "hunk": return "@@";
4300
+ default: return "#";
4301
+ }
4302
+ }
4303
+
4304
+ function scanMarkdownOutlineEntries(text) {
4305
+ const { source, lines, lineOffsets } = buildOutlineLineIndex(text);
4306
+ const entries = [];
4307
+ let activeFence = null;
4308
+
4309
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4310
+ const line = String(lines[lineIndex] || "");
4311
+ const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
4312
+ if (fenceMatch) {
4313
+ if (!activeFence) {
4314
+ activeFence = fenceMatch[1];
4315
+ } else if (fenceMatch[1][0] === activeFence[0] && fenceMatch[1].length >= activeFence.length) {
4316
+ activeFence = null;
4317
+ }
4318
+ continue;
4319
+ }
4320
+ if (activeFence) continue;
4321
+
4322
+ const atxMatch = line.match(/^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/);
4323
+ if (atxMatch) {
4324
+ const label = normalizeVisiblePreviewText(atxMatch[2] || "");
4325
+ const entry = makeOutlineEntry({
4326
+ kind: atxMatch[1].length === 1 ? "section" : atxMatch[1].length === 2 ? "subsection" : atxMatch[1].length === 3 ? "subsubsection" : "heading",
4327
+ depth: atxMatch[1].length,
4328
+ label,
4329
+ lineStart: lineIndex + 1,
4330
+ lineEnd: lineIndex + 1,
4331
+ selectionStart: lineOffsets[lineIndex] || 0,
4332
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4333
+ selectedText: line,
4334
+ selectedDisplayText: label,
4335
+ });
4336
+ if (entry) entries.push(entry);
4337
+ continue;
4338
+ }
4339
+
4340
+ const nextLine = lineIndex + 1 < lines.length ? String(lines[lineIndex + 1] || "") : "";
4341
+ const setextMatch = nextLine.match(/^ {0,3}(=+|-+)\s*$/);
4342
+ if (setextMatch && normalizeVisiblePreviewText(line)) {
4343
+ const depth = setextMatch[1][0] === "=" ? 1 : 2;
4344
+ const label = normalizeVisiblePreviewText(line);
4345
+ const entry = makeOutlineEntry({
4346
+ kind: depth === 1 ? "section" : "subsection",
4347
+ depth,
4348
+ label,
4349
+ lineStart: lineIndex + 1,
4350
+ lineEnd: lineIndex + 1,
4351
+ selectionStart: lineOffsets[lineIndex] || 0,
4352
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4353
+ selectedText: line,
4354
+ selectedDisplayText: label,
4355
+ });
4356
+ if (entry) entries.push(entry);
4357
+ lineIndex += 1;
4358
+ }
4359
+ }
4360
+
4361
+ return entries;
4362
+ }
4363
+
4364
+ const LATEX_OUTLINE_LEVEL_BY_COMMAND = {
4365
+ part: 1,
4366
+ chapter: 1,
4367
+ section: 1,
4368
+ subsection: 2,
4369
+ subsubsection: 3,
4370
+ paragraph: 4,
4371
+ subparagraph: 5,
4372
+ };
4373
+
4374
+ function scanLatexOutlineEntries(text) {
4375
+ const source = String(text || "").replace(/\r\n/g, "\n");
4376
+ const bodyRange = findLatexDocumentBodyRange(source);
4377
+ const bodyStart = Math.max(0, Math.min(bodyRange.start, source.length));
4378
+ const bodyEnd = Math.max(bodyStart, Math.min(bodyRange.end, source.length));
4379
+ const bodyText = source.slice(bodyStart, bodyEnd);
4380
+ const { lines, lineOffsets } = buildOutlineLineIndex(bodyText);
4381
+ const entries = [];
4382
+
4383
+ function getLine(index) {
4384
+ return index >= 0 && index < lines.length ? String(lines[index] || "") : "";
4385
+ }
4386
+
4387
+ function getStrippedLine(index) {
4388
+ return stripLatexPreviewComments(getLine(index)).trim();
4389
+ }
4390
+
4391
+ function isBibliographyCommandLine(index) {
4392
+ return /^\\(?:bibliographystyle|bibliography|printbibliography)\b/i.test(getStrippedLine(index));
4393
+ }
4394
+
4395
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4396
+ let chunk = getLine(lineIndex);
4397
+ let endLineIndex = lineIndex;
4398
+ let heading = readLatexHeadingChunk(chunk);
4399
+ if (/^\s*\\(?:part|chapter|section|subsection|subsubsection|paragraph|subparagraph)\b/.test(chunk)) {
4400
+ while (!heading && endLineIndex + 1 < lines.length && endLineIndex < lineIndex + 5) {
4401
+ endLineIndex += 1;
4402
+ chunk += "\n" + getLine(endLineIndex);
4403
+ heading = readLatexHeadingChunk(chunk);
4404
+ }
4405
+ }
4406
+ if (heading) {
4407
+ const label = extractLatexPreviewVisibleText(heading.titleText || "");
4408
+ const kind = String(heading.commandName || "section").replace(/\*$/, "").toLowerCase();
4409
+ const entry = makeOutlineEntry({
4410
+ kind,
4411
+ depth: LATEX_OUTLINE_LEVEL_BY_COMMAND[kind] || 1,
4412
+ label,
4413
+ lineStart: lineIndex + 1,
4414
+ lineEnd: endLineIndex + 1,
4415
+ selectionStart: bodyStart + (lineOffsets[lineIndex] || 0),
4416
+ selectionEnd: bodyStart + (lineOffsets[endLineIndex] || 0) + getLine(endLineIndex).length,
4417
+ selectedText: source.slice(bodyStart + (lineOffsets[lineIndex] || 0), bodyStart + (lineOffsets[endLineIndex] || 0) + getLine(endLineIndex).length),
4418
+ selectedDisplayText: label,
4419
+ });
4420
+ if (entry) entries.push(entry);
4421
+ lineIndex = endLineIndex;
4422
+ continue;
4423
+ }
4424
+
4425
+ if (isBibliographyCommandLine(lineIndex)) {
4426
+ let endLine = lineIndex;
4427
+ while (endLine + 1 < lines.length && isBibliographyCommandLine(endLine + 1)) {
4428
+ endLine += 1;
4429
+ }
4430
+ const entry = makeOutlineEntry({
4431
+ kind: "section",
4432
+ depth: 1,
4433
+ label: "References",
4434
+ lineStart: lineIndex + 1,
4435
+ lineEnd: endLine + 1,
4436
+ selectionStart: bodyStart + (lineOffsets[lineIndex] || 0),
4437
+ selectionEnd: bodyStart + (lineOffsets[endLine] || 0) + getLine(endLine).length,
4438
+ selectedText: source.slice(bodyStart + (lineOffsets[lineIndex] || 0), bodyStart + (lineOffsets[endLine] || 0) + getLine(endLine).length),
4439
+ selectedDisplayText: "References",
4440
+ });
4441
+ if (entry) entries.push(entry);
4442
+ lineIndex = endLine;
4443
+ }
4444
+ }
4445
+
4446
+ return entries;
4447
+ }
4448
+
4449
+ function scanPythonOutlineEntries(text) {
4450
+ const { lines, lineOffsets } = buildOutlineLineIndex(text);
4451
+ const entries = [];
4452
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4453
+ const line = String(lines[lineIndex] || "");
4454
+ const classMatch = line.match(/^(\s*)class\s+([A-Za-z_][A-Za-z0-9_]*)\b/);
4455
+ const defMatch = line.match(/^(\s*)(?:async\s+def|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/);
4456
+ const match = classMatch || defMatch;
4457
+ if (!match) continue;
4458
+ const indent = String(match[1] || "").replace(/\t/g, " ").length;
4459
+ const label = String(match[2] || "");
4460
+ const kind = classMatch ? "class" : "function";
4461
+ const entry = makeOutlineEntry({
4462
+ kind,
4463
+ depth: Math.max(1, Math.floor(indent / 4) + 1),
4464
+ label,
4465
+ lineStart: lineIndex + 1,
4466
+ lineEnd: lineIndex + 1,
4467
+ selectionStart: lineOffsets[lineIndex] || 0,
4468
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4469
+ selectedText: line,
4470
+ selectedDisplayText: label,
4471
+ });
4472
+ if (entry) entries.push(entry);
4473
+ }
4474
+ return entries;
4475
+ }
4476
+
4477
+ function scanJsLikeOutlineEntries(text) {
4478
+ const { lines, lineOffsets } = buildOutlineLineIndex(text);
4479
+ const entries = [];
4480
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4481
+ const line = String(lines[lineIndex] || "");
4482
+ const patterns = [
4483
+ { kind: "class", match: line.match(/^(\s*)(?:export\s+)?(?:default\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)\b/) },
4484
+ { kind: "function", match: line.match(/^(\s*)(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/) },
4485
+ { kind: "function", match: line.match(/^(\s*)(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/) },
4486
+ { kind: "interface", match: line.match(/^(\s*)(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)\b/) },
4487
+ { kind: "enum", match: line.match(/^(\s*)(?:export\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)\b/) },
4488
+ { kind: "type", match: line.match(/^(\s*)(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\b/) },
4489
+ ];
4490
+ const found = patterns.find((entry) => entry.match);
4491
+ if (!found || !found.match) continue;
4492
+ const indent = String(found.match[1] || "").replace(/\t/g, " ").length;
4493
+ const label = String(found.match[2] || "");
4494
+ const entry = makeOutlineEntry({
4495
+ kind: found.kind,
4496
+ depth: Math.max(1, Math.floor(indent / 2) + 1),
4497
+ label,
4498
+ lineStart: lineIndex + 1,
4499
+ lineEnd: lineIndex + 1,
4500
+ selectionStart: lineOffsets[lineIndex] || 0,
4501
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4502
+ selectedText: line,
4503
+ selectedDisplayText: label,
4504
+ });
4505
+ if (entry) entries.push(entry);
4506
+ }
4507
+ return entries;
4508
+ }
4509
+
4510
+ function scanJuliaOutlineEntries(text) {
4511
+ const { lines, lineOffsets } = buildOutlineLineIndex(text);
4512
+ const entries = [];
4513
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4514
+ const line = String(lines[lineIndex] || "");
4515
+ const patterns = [
4516
+ { kind: "module", match: line.match(/^(\s*)module\s+([A-Za-z_][A-Za-z0-9_]*)\b/) },
4517
+ { kind: "struct", match: line.match(/^(\s*)(?:mutable\s+)?struct\s+([A-Za-z_][A-Za-z0-9_]*)\b/) },
4518
+ { kind: "function", match: line.match(/^(\s*)function\s+([A-Za-z_][A-Za-z0-9_!]*)\s*\(/) },
4519
+ { kind: "macro", match: line.match(/^(\s*)macro\s+([A-Za-z_][A-Za-z0-9_!]*)\b/) },
4520
+ ];
4521
+ const found = patterns.find((entry) => entry.match);
4522
+ if (!found || !found.match) continue;
4523
+ const indent = String(found.match[1] || "").replace(/\t/g, " ").length;
4524
+ const label = String(found.match[2] || "");
4525
+ const entry = makeOutlineEntry({
4526
+ kind: found.kind,
4527
+ depth: Math.max(1, Math.floor(indent / 2) + 1),
4528
+ label,
4529
+ lineStart: lineIndex + 1,
4530
+ lineEnd: lineIndex + 1,
4531
+ selectionStart: lineOffsets[lineIndex] || 0,
4532
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4533
+ selectedText: line,
4534
+ selectedDisplayText: label,
4535
+ });
4536
+ if (entry) entries.push(entry);
4537
+ }
4538
+ return entries;
4539
+ }
4540
+
4541
+ function scanBashOutlineEntries(text) {
4542
+ const { lines, lineOffsets } = buildOutlineLineIndex(text);
4543
+ const entries = [];
4544
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4545
+ const line = String(lines[lineIndex] || "");
4546
+ const match = line.match(/^(\s*)(?:function\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{/);
4547
+ if (!match) continue;
4548
+ const indent = String(match[1] || "").replace(/\t/g, " ").length;
4549
+ const label = String(match[2] || "");
4550
+ const entry = makeOutlineEntry({
4551
+ kind: "function",
4552
+ depth: Math.max(1, Math.floor(indent / 2) + 1),
4553
+ label,
4554
+ lineStart: lineIndex + 1,
4555
+ lineEnd: lineIndex + 1,
4556
+ selectionStart: lineOffsets[lineIndex] || 0,
4557
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4558
+ selectedText: line,
4559
+ selectedDisplayText: label,
4560
+ });
4561
+ if (entry) entries.push(entry);
4562
+ }
4563
+ return entries;
4564
+ }
4565
+
4566
+ function scanDiffOutlineEntries(text) {
4567
+ const { lines, lineOffsets } = buildOutlineLineIndex(text);
4568
+ const entries = [];
4569
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
4570
+ const line = String(lines[lineIndex] || "");
4571
+ let kind = "";
4572
+ let label = "";
4573
+ let depth = 1;
4574
+ const fileMatch = line.match(/^diff\s+--git\s+a\/([^\s]+)\s+b\/([^\s]+)/);
4575
+ if (fileMatch) {
4576
+ kind = "file";
4577
+ label = String(fileMatch[2] || fileMatch[1] || "");
4578
+ depth = 1;
4579
+ } else if (/^@@/.test(line)) {
4580
+ kind = "hunk";
4581
+ label = line.replace(/^@@\s*|\s*@@.*$/g, "").trim() || line.trim();
4582
+ depth = 2;
4583
+ }
4584
+ if (!kind || !label) continue;
4585
+ const entry = makeOutlineEntry({
4586
+ kind,
4587
+ depth,
4588
+ label,
4589
+ lineStart: lineIndex + 1,
4590
+ lineEnd: lineIndex + 1,
4591
+ selectionStart: lineOffsets[lineIndex] || 0,
4592
+ selectionEnd: (lineOffsets[lineIndex] || 0) + line.length,
4593
+ selectedText: line,
4594
+ selectedDisplayText: label,
4595
+ });
4596
+ if (entry) entries.push(entry);
4597
+ }
4598
+ return entries;
4599
+ }
4600
+
4601
+ function scanOutlineEntries(text, language) {
4602
+ switch (String(language || "").toLowerCase()) {
4603
+ case "markdown":
4604
+ return scanMarkdownOutlineEntries(text);
4605
+ case "latex":
4606
+ return scanLatexOutlineEntries(text);
4607
+ case "python":
4608
+ return scanPythonOutlineEntries(text);
4609
+ case "javascript":
4610
+ case "typescript":
4611
+ return scanJsLikeOutlineEntries(text);
4612
+ case "julia":
4613
+ return scanJuliaOutlineEntries(text);
4614
+ case "bash":
4615
+ return scanBashOutlineEntries(text);
4616
+ case "diff":
4617
+ return scanDiffOutlineEntries(text);
4618
+ default:
4619
+ return [];
4620
+ }
4621
+ }
4622
+
4201
4623
  function cloneReviewNotes(notes) {
4202
4624
  return Array.isArray(notes)
4203
4625
  ? notes
@@ -7011,6 +7433,138 @@
7011
7433
  updateEditorSelectionCommentUi();
7012
7434
  }
7013
7435
 
7436
+ function getOutlineEntriesForCurrentEditor() {
7437
+ return scanOutlineEntries(sourceTextEl && sourceTextEl.value ? sourceTextEl.value : "", editorLanguage || "markdown");
7438
+ }
7439
+
7440
+ function updateOutlineUi() {
7441
+ outlineEntries = getOutlineEntriesForCurrentEditor();
7442
+ const descriptor = getCurrentStudioDocumentDescriptor();
7443
+ const count = outlineEntries.length;
7444
+ const hasEntries = count > 0;
7445
+ const isOpen = isOutlineOpen();
7446
+ if (outlineBtn) {
7447
+ outlineBtn.textContent = "Outline";
7448
+ outlineBtn.classList.remove("has-content");
7449
+ outlineBtn.classList.toggle("is-active", isOpen);
7450
+ outlineBtn.setAttribute("aria-pressed", isOpen ? "true" : "false");
7451
+ outlineBtn.title = isOpen
7452
+ ? "Hide document outline."
7453
+ : (hasEntries
7454
+ ? (count + " outline entr" + (count === 1 ? "y" : "ies") + " for " + descriptor.label + ". Open the outline rail.")
7455
+ : "Open document outline for the current editor text.");
7456
+ }
7457
+ if (outlineMetaEl) {
7458
+ outlineMetaEl.textContent = hasEntries
7459
+ ? (count + " entr" + (count === 1 ? "y" : "ies") + " · " + (editorLanguage || "text") + " · " + descriptor.label)
7460
+ : ("No outline entries · " + (editorLanguage || "text"));
7461
+ }
7462
+ if (outlineDoneBtn) {
7463
+ outlineDoneBtn.disabled = !isOpen;
7464
+ }
7465
+ if (outlineEmptyStateEl) {
7466
+ outlineEmptyStateEl.hidden = hasEntries;
7467
+ }
7468
+ renderOutlineList();
7469
+ }
7470
+
7471
+ function renderOutlineList() {
7472
+ if (!outlineListEl) return;
7473
+ outlineListEl.innerHTML = "";
7474
+ for (const entry of outlineEntries) {
7475
+ const itemBtn = document.createElement("button");
7476
+ itemBtn.type = "button";
7477
+ itemBtn.className = "outline-entry";
7478
+ itemBtn.dataset.outlineId = String(entry.id || "");
7479
+ itemBtn.style.paddingLeft = (10 + Math.max(0, (entry.depth || 1) - 1) * 14) + "px";
7480
+ itemBtn.title = getOutlineKindLabel(entry.kind) + " · line " + String(entry.lineStart || 1) + "\n" + String(entry.label || "");
7481
+
7482
+ const kindEl = document.createElement("span");
7483
+ kindEl.className = "outline-entry-kind";
7484
+ kindEl.textContent = getOutlineKindBadge(entry.kind);
7485
+ itemBtn.appendChild(kindEl);
7486
+
7487
+ const titleEl = document.createElement("span");
7488
+ titleEl.className = "outline-entry-title";
7489
+ titleEl.textContent = String(entry.label || "");
7490
+ itemBtn.appendChild(titleEl);
7491
+
7492
+ const metaEl = document.createElement("span");
7493
+ metaEl.className = "outline-entry-meta";
7494
+ metaEl.textContent = "L" + String(entry.lineStart || 1);
7495
+ itemBtn.appendChild(metaEl);
7496
+
7497
+ outlineListEl.appendChild(itemBtn);
7498
+ }
7499
+ }
7500
+
7501
+ function buildOutlineEntryAnchor(entry) {
7502
+ if (!entry) return null;
7503
+ return normalizeReviewNote({
7504
+ selectionStart: entry.selectionStart,
7505
+ selectionEnd: entry.selectionEnd,
7506
+ lineStart: entry.lineStart,
7507
+ lineEnd: entry.lineEnd,
7508
+ selectedText: entry.selectedText,
7509
+ selectedDisplayText: entry.selectedDisplayText || entry.label,
7510
+ });
7511
+ }
7512
+
7513
+ function jumpToOutlineEntry(entryId) {
7514
+ const entry = outlineEntries.find((candidate) => candidate && String(candidate.id || "") === String(entryId || ""));
7515
+ if (!entry) return false;
7516
+ const anchor = buildOutlineEntryAnchor(entry);
7517
+ if (!anchor) return false;
7518
+ return jumpToReviewAnchor(anchor, {
7519
+ statusMessage: "Jumped to outline entry.",
7520
+ afterJump: () => {
7521
+ revealReviewNoteInPreview(anchor);
7522
+ },
7523
+ });
7524
+ }
7525
+
7526
+ function closeOutline(options) {
7527
+ if (!outlineOverlayEl || outlineOverlayEl.hidden) return;
7528
+ outlineOverlayEl.hidden = true;
7529
+ updateOutlineUi();
7530
+ if (editorView === "markdown") {
7531
+ scheduleEditorLineNumberRender();
7532
+ }
7533
+ const focusTarget = options && Object.prototype.hasOwnProperty.call(options, "focusTarget")
7534
+ ? options.focusTarget
7535
+ : (outlineReturnFocusEl || outlineBtn || sourceTextEl);
7536
+ outlineReturnFocusEl = null;
7537
+ if (focusTarget && typeof focusTarget.focus === "function") {
7538
+ const schedule = typeof window.requestAnimationFrame === "function"
7539
+ ? window.requestAnimationFrame.bind(window)
7540
+ : (cb) => window.setTimeout(cb, 16);
7541
+ schedule(() => focusTarget.focus());
7542
+ }
7543
+ }
7544
+
7545
+ function openOutline() {
7546
+ if (!outlineOverlayEl) return;
7547
+ if (isReviewNotesOpen()) {
7548
+ closeReviewNotes({ focusTarget: null });
7549
+ }
7550
+ outlineReturnFocusEl = document.activeElement && document.activeElement !== document.body
7551
+ ? document.activeElement
7552
+ : sourceTextEl;
7553
+ outlineOverlayEl.hidden = false;
7554
+ updateOutlineUi();
7555
+ if (editorView === "markdown") {
7556
+ scheduleEditorLineNumberRender();
7557
+ }
7558
+ }
7559
+
7560
+ function toggleOutline() {
7561
+ if (isOutlineOpen()) {
7562
+ closeOutline({ focusTarget: outlineBtn || sourceTextEl });
7563
+ } else {
7564
+ openOutline();
7565
+ }
7566
+ }
7567
+
7014
7568
  function updateReviewNotesUi() {
7015
7569
  const descriptor = getCurrentStudioDocumentDescriptor();
7016
7570
  const count = reviewNotes.length;
@@ -7382,6 +7936,18 @@
7382
7936
  selection.removeAllRanges();
7383
7937
  }
7384
7938
  clearPreviewCommentSelection();
7939
+ const current = String(sourceTextEl && sourceTextEl.value ? sourceTextEl.value : "");
7940
+ const range = resolveReviewNoteRange(previewNote, current);
7941
+ if (range && sourceTextEl) {
7942
+ try {
7943
+ sourceTextEl.focus({ preventScroll: true });
7944
+ } catch {
7945
+ sourceTextEl.focus();
7946
+ }
7947
+ if (typeof sourceTextEl.setSelectionRange === "function") {
7948
+ sourceTextEl.setSelectionRange(range.start, range.end);
7949
+ }
7950
+ }
7385
7951
  });
7386
7952
  },
7387
7953
  });
@@ -7537,6 +8103,9 @@
7537
8103
  if (isReviewNotesOpen()) {
7538
8104
  closeReviewNotes({ focusTarget: null });
7539
8105
  }
8106
+ if (isOutlineOpen()) {
8107
+ closeOutline({ focusTarget: null });
8108
+ }
7540
8109
  scratchpadReturnFocusEl = document.activeElement && document.activeElement !== document.body
7541
8110
  ? document.activeElement
7542
8111
  : sourceTextEl;
@@ -7580,6 +8149,9 @@
7580
8149
  if (isScratchpadOpen()) {
7581
8150
  closeScratchpad({ focusTarget: null });
7582
8151
  }
8152
+ if (isOutlineOpen()) {
8153
+ closeOutline({ focusTarget: null });
8154
+ }
7583
8155
  reviewNotesReturnFocusEl = document.activeElement && document.activeElement !== document.body
7584
8156
  ? document.activeElement
7585
8157
  : sourceTextEl;
@@ -7697,6 +8269,7 @@
7697
8269
  if (editorView === "preview") {
7698
8270
  scheduleSourcePreviewRender(0);
7699
8271
  }
8272
+ updateOutlineUi();
7700
8273
  }
7701
8274
 
7702
8275
  function setEditorHighlightMode(mode) {
@@ -8896,6 +9469,7 @@
8896
9469
  renderSourcePreview({ previewDelayMs: PREVIEW_INPUT_DEBOUNCE_MS });
8897
9470
  scheduleEditorMetaUpdate();
8898
9471
  updateEditorSelectionCommentUi();
9472
+ updateOutlineUi();
8899
9473
  if (isReviewNotesOpen() && reviewNotes.length > 0) {
8900
9474
  renderReviewNotesList();
8901
9475
  updateReviewNotesUi();
@@ -9284,6 +9858,35 @@
9284
9858
  });
9285
9859
  }
9286
9860
 
9861
+ if (outlineBtn) {
9862
+ outlineBtn.addEventListener("click", () => {
9863
+ toggleOutline();
9864
+ });
9865
+ }
9866
+
9867
+ if (outlineCloseBtn) {
9868
+ outlineCloseBtn.addEventListener("click", () => {
9869
+ closeOutline();
9870
+ });
9871
+ }
9872
+
9873
+ if (outlineDoneBtn) {
9874
+ outlineDoneBtn.addEventListener("click", () => {
9875
+ closeOutline();
9876
+ });
9877
+ }
9878
+
9879
+ if (outlineListEl) {
9880
+ outlineListEl.addEventListener("click", (event) => {
9881
+ const target = event.target;
9882
+ const entryBtn = target instanceof Element ? target.closest(".outline-entry") : null;
9883
+ if (!entryBtn) return;
9884
+ const outlineId = entryBtn.getAttribute("data-outline-id") || "";
9885
+ if (!outlineId) return;
9886
+ jumpToOutlineEntry(outlineId);
9887
+ });
9888
+ }
9889
+
9287
9890
  if (reviewNotesCloseBtn) {
9288
9891
  reviewNotesCloseBtn.addEventListener("click", () => {
9289
9892
  closeReviewNotes();
package/client/studio.css CHANGED
@@ -227,13 +227,15 @@
227
227
 
228
228
  #scratchpadBtn.has-content,
229
229
  #reviewNotesBtn.has-content,
230
- #reviewNotesBtn.is-active {
230
+ #reviewNotesBtn.is-active,
231
+ #outlineBtn.is-active {
231
232
  border-color: var(--accent);
232
233
  color: var(--accent);
233
234
  font-weight: 600;
234
235
  }
235
236
 
236
- #reviewNotesBtn.is-active {
237
+ #reviewNotesBtn.is-active,
238
+ #outlineBtn.is-active {
237
239
  background: var(--accent-soft);
238
240
  }
239
241
 
@@ -336,7 +338,8 @@
336
338
  gap: 8px;
337
339
  }
338
340
 
339
- .review-notes-dock-wrap {
341
+ .review-notes-dock-wrap,
342
+ .outline-dock-wrap {
340
343
  flex: 0 0 clamp(300px, 34%, 420px);
341
344
  min-width: 280px;
342
345
  min-height: 0;
@@ -348,11 +351,13 @@
348
351
  overflow: hidden;
349
352
  }
350
353
 
351
- .review-notes-dock-wrap[hidden] {
354
+ .review-notes-dock-wrap[hidden],
355
+ .outline-dock-wrap[hidden] {
352
356
  display: none !important;
353
357
  }
354
358
 
355
- .review-notes-dock {
359
+ .review-notes-dock,
360
+ .outline-dock {
356
361
  width: 100%;
357
362
  min-height: 0;
358
363
  display: flex;
@@ -1824,15 +1829,18 @@
1824
1829
  filter: brightness(0.95);
1825
1830
  }
1826
1831
 
1827
- .review-notes-dock .scratchpad-header {
1832
+ .review-notes-dock .scratchpad-header,
1833
+ .outline-dock .scratchpad-header {
1828
1834
  padding: 12px 14px 10px;
1829
1835
  }
1830
1836
 
1831
- .review-notes-dock .scratchpad-header h2 {
1837
+ .review-notes-dock .scratchpad-header h2,
1838
+ .outline-dock .scratchpad-header h2 {
1832
1839
  font-size: 15px;
1833
1840
  }
1834
1841
 
1835
- .review-notes-dock .scratchpad-description {
1842
+ .review-notes-dock .scratchpad-description,
1843
+ .outline-dock .scratchpad-description {
1836
1844
  font-size: 11px;
1837
1845
  line-height: 1.4;
1838
1846
  word-break: normal;
@@ -1895,6 +1903,60 @@
1895
1903
  justify-content: space-between;
1896
1904
  }
1897
1905
 
1906
+ .outline-list {
1907
+ display: flex;
1908
+ flex: 1 1 auto;
1909
+ min-height: 0;
1910
+ flex-direction: column;
1911
+ gap: 6px;
1912
+ padding: 14px;
1913
+ overflow: auto;
1914
+ background: var(--panel);
1915
+ }
1916
+
1917
+ .outline-entry {
1918
+ width: 100%;
1919
+ display: grid;
1920
+ grid-template-columns: auto 1fr auto;
1921
+ align-items: baseline;
1922
+ gap: 8px;
1923
+ text-align: left;
1924
+ border-radius: 10px;
1925
+ padding: 8px 10px;
1926
+ background: var(--panel-2);
1927
+ border: 1px solid var(--border-muted);
1928
+ }
1929
+
1930
+ .outline-entry:hover,
1931
+ .outline-entry:focus-visible {
1932
+ border-color: var(--accent);
1933
+ background: var(--accent-soft);
1934
+ }
1935
+
1936
+ .outline-entry-kind {
1937
+ font-size: 11px;
1938
+ font-weight: 700;
1939
+ letter-spacing: 0.03em;
1940
+ text-transform: uppercase;
1941
+ color: var(--accent);
1942
+ white-space: nowrap;
1943
+ }
1944
+
1945
+ .outline-entry-title {
1946
+ min-width: 0;
1947
+ font-size: 13px;
1948
+ line-height: 1.35;
1949
+ color: var(--text);
1950
+ overflow-wrap: anywhere;
1951
+ }
1952
+
1953
+ .outline-entry-meta {
1954
+ font-size: 11px;
1955
+ color: var(--muted);
1956
+ white-space: nowrap;
1957
+ font-variant-numeric: tabular-nums;
1958
+ }
1959
+
1898
1960
  .review-note-card {
1899
1961
  border: 1px solid var(--border-muted);
1900
1962
  border-radius: 12px;
@@ -2002,7 +2064,8 @@
2002
2064
  flex-direction: column;
2003
2065
  }
2004
2066
 
2005
- .review-notes-dock-wrap {
2067
+ .review-notes-dock-wrap,
2068
+ .outline-dock-wrap {
2006
2069
  flex: 0 0 auto;
2007
2070
  min-width: 0;
2008
2071
  max-height: min(42vh, 420px);
package/index.ts CHANGED
@@ -6010,6 +6010,7 @@ ${cssVarsBlock}
6010
6010
  <div class="section-header-actions">
6011
6011
  <button id="leftFocusBtn" class="pane-focus-btn" type="button" title="Show only the editor pane. Shortcut: F10 or Cmd/Ctrl+Esc.">Focus pane</button>
6012
6012
  <button id="reviewNotesBtn" type="button" title="Toggle local comments beside the current editor document or draft. Comments stay outside the document text and can later be converted into [an: ...] annotations.">Comments</button>
6013
+ <button id="outlineBtn" type="button" title="Toggle document outline for the current editor text. Outline entries can jump between raw editor and preview.">Outline</button>
6013
6014
  <button id="scratchpadBtn" type="button" title="Open a local persistent scratchpad for the current editor document or draft. Scratchpad text is never run, critiqued, or exported unless you explicitly insert it into the editor.">Scratchpad</button>
6014
6015
  </div>
6015
6016
  </div>
@@ -6102,6 +6103,27 @@ ${cssVarsBlock}
6102
6103
  </div>
6103
6104
  <div id="sourcePreview" class="panel-scroll rendered-markdown" hidden><pre class="plain-markdown"></pre></div>
6104
6105
  </div>
6106
+ <aside id="outlineOverlay" class="outline-dock-wrap" hidden>
6107
+ <div id="outlineDialog" class="outline-dock" role="complementary" aria-labelledby="outlineTitle">
6108
+ <div class="scratchpad-header">
6109
+ <div>
6110
+ <h2 id="outlineTitle">Outline</h2>
6111
+ <p class="scratchpad-description">Document structure for the current editor text. Click an entry to jump in the raw editor and, when available, reveal the matching preview location.</p>
6112
+ </div>
6113
+ <button id="outlineCloseBtn" type="button" class="scratchpad-close-btn" aria-label="Hide outline" title="Hide outline">✕</button>
6114
+ </div>
6115
+ <div class="review-notes-toolbar">
6116
+ <span id="outlineMeta" class="scratchpad-meta">No outline entries</span>
6117
+ </div>
6118
+ <div id="outlineEmptyState" class="review-notes-empty">No outline available yet for this document or syntax mode.</div>
6119
+ <div id="outlineList" class="outline-list" aria-live="polite"></div>
6120
+ <div class="review-notes-dock-footer">
6121
+ <div class="scratchpad-actions">
6122
+ <button id="outlineDoneBtn" type="button" title="Hide the outline rail.">Hide</button>
6123
+ </div>
6124
+ </div>
6125
+ </div>
6126
+ </aside>
6105
6127
  <aside id="reviewNotesOverlay" class="review-notes-dock-wrap" hidden>
6106
6128
  <div id="reviewNotesDialog" class="review-notes-dock" role="complementary" aria-labelledby="reviewNotesTitle">
6107
6129
  <div class="scratchpad-header">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-studio",
3
- "version": "0.5.51",
3
+ "version": "0.5.52",
4
4
  "description": "Two-pane browser workspace for pi with prompt/response editing, annotations, critiques, prompt/response history, and live Markdown/LaTeX/code preview",
5
5
  "type": "module",
6
6
  "license": "MIT",