@unabridged/midwest 0.23.0 → 0.23.1

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.
@@ -5374,20 +5374,97 @@ function cloneTemplate(template) {
5374
5374
  return el;
5375
5375
  }
5376
5376
 
5377
+ const PLACEHOLDER = "\uE000";
5378
+ function renderMarkdown(src) {
5379
+ const blocks = [];
5380
+ const withoutFences = src.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
5381
+ const body = escapeHtml(code.replace(/\n$/, ""));
5382
+ blocks.push(`<pre class="command-palette-code"><code>${body}</code></pre>`);
5383
+ return `${PLACEHOLDER}${blocks.length - 1}${PLACEHOLDER}`;
5384
+ });
5385
+ const lines = escapeHtml(withoutFences).split("\n");
5386
+ const out = [];
5387
+ let listOpen = false;
5388
+ const closeList = () => {
5389
+ if (listOpen) {
5390
+ out.push("</ul>");
5391
+ listOpen = false;
5392
+ }
5393
+ };
5394
+ for (const line of lines) {
5395
+ const placeholder = line.match(new RegExp(`^${PLACEHOLDER}(\\d+)${PLACEHOLDER}$`));
5396
+ if (placeholder) {
5397
+ closeList();
5398
+ out.push(line);
5399
+ continue;
5400
+ }
5401
+ const item = line.match(/^\s*[-*]\s+(.*)$/);
5402
+ if (item) {
5403
+ if (!listOpen) {
5404
+ out.push("<ul>");
5405
+ listOpen = true;
5406
+ }
5407
+ out.push(`<li>${inline(item[1])}</li>`);
5408
+ continue;
5409
+ }
5410
+ closeList();
5411
+ if (line.trim() !== "")
5412
+ out.push(`<p>${inline(line)}</p>`);
5413
+ }
5414
+ closeList();
5415
+ return out.join("\n").replace(new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER}`, "g"), (_match, index) => blocks[Number(index)]);
5416
+ }
5417
+ function inline(text) {
5418
+ return text.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(
5419
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
5420
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
5421
+ );
5422
+ }
5423
+ function escapeHtml(text) {
5424
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5425
+ }
5426
+
5377
5427
  class CommandPalette extends Controller {
5378
- static targets = ["input", "list", "empty", "body", "turboList", "itemTemplate", "groupTemplate"];
5428
+ static targets = [
5429
+ "input",
5430
+ "list",
5431
+ "empty",
5432
+ "body",
5433
+ "turboList",
5434
+ "itemTemplate",
5435
+ "groupTemplate",
5436
+ "modes",
5437
+ "chat",
5438
+ "transcript",
5439
+ "starters",
5440
+ "messageTemplate",
5441
+ "actionTemplate"
5442
+ ];
5379
5443
  static values = {
5380
5444
  hotkey: { type: String, default: "k" },
5381
5445
  items: { type: Array, default: [] },
5382
5446
  src: { type: String, default: "" },
5383
- turboFrameUrl: { type: String, default: "" }
5447
+ turboFrameUrl: { type: String, default: "" },
5448
+ chatEnabled: { type: Boolean, default: false },
5449
+ chatMode: { type: String, default: "proxy" },
5450
+ chatTrigger: { type: String, default: "toggle" },
5451
+ chatAgentic: { type: Boolean, default: true },
5452
+ chatPlaceholder: { type: String, default: "Ask AI\u2026" },
5453
+ chatEndpoint: { type: String, default: "" },
5454
+ chatUrl: { type: String, default: "" },
5455
+ chatModel: { type: String, default: "" }
5384
5456
  };
5385
5457
  highlightedIndex = -1;
5386
5458
  fetchAbort = null;
5387
5459
  searchTimer;
5388
5460
  allItems = [];
5461
+ mode = "command";
5462
+ commandPlaceholder = "";
5463
+ messages = [];
5464
+ streamAbort = null;
5389
5465
  connect() {
5390
5466
  this.allItems = this.itemsValue;
5467
+ this.commandPlaceholder = this.inputTarget.placeholder;
5391
5468
  document.addEventListener("keydown", this.handleGlobalKeydown);
5392
5469
  this.element.addEventListener("click", this.handleBackdropClick);
5393
5470
  if (!this.turboFrameUrlValue) {
@@ -5399,6 +5476,8 @@ class CommandPalette extends Controller {
5399
5476
  this.element.removeEventListener("click", this.handleBackdropClick);
5400
5477
  if (this.fetchAbort)
5401
5478
  this.fetchAbort.abort();
5479
+ if (this.streamAbort)
5480
+ this.streamAbort.abort();
5402
5481
  if (this.searchTimer)
5403
5482
  clearTimeout(this.searchTimer);
5404
5483
  }
@@ -5432,6 +5511,8 @@ class CommandPalette extends Controller {
5432
5511
  this.element.showModal();
5433
5512
  this.inputTarget.value = "";
5434
5513
  this.highlightedIndex = -1;
5514
+ if (this.chatEnabledValue)
5515
+ this.resetChat();
5435
5516
  if (this.turboFrameUrlValue) {
5436
5517
  this.navigateFrame("");
5437
5518
  } else {
@@ -5445,6 +5526,8 @@ class CommandPalette extends Controller {
5445
5526
  this.element.close();
5446
5527
  }
5447
5528
  filter() {
5529
+ if (this.mode === "chat")
5530
+ return;
5448
5531
  const query = this.inputTarget.value.trim().toLowerCase();
5449
5532
  if (this.turboFrameUrlValue) {
5450
5533
  this.debouncedNavigateFrame(query);
@@ -5465,6 +5548,21 @@ class CommandPalette extends Controller {
5465
5548
  this.renderItems(filtered);
5466
5549
  }
5467
5550
  navigate(event) {
5551
+ if (this.mode === "chat") {
5552
+ if (event.key === "Enter" && !event.shiftKey) {
5553
+ event.preventDefault();
5554
+ this.send();
5555
+ } else if (event.key === "Escape") {
5556
+ event.preventDefault();
5557
+ this.close();
5558
+ }
5559
+ return;
5560
+ }
5561
+ if (event.key === "Tab" && !event.shiftKey && this.chatEnabledValue && this.chatTriggerValue === "toggle" && this.inputTarget.value.trim() === "") {
5562
+ event.preventDefault();
5563
+ this.setMode("chat");
5564
+ return;
5565
+ }
5468
5566
  const items = this.visibleItems;
5469
5567
  switch (event.key) {
5470
5568
  case "ArrowDown":
@@ -5499,6 +5597,225 @@ class CommandPalette extends Controller {
5499
5597
  this.selectItem(items[index]);
5500
5598
  }
5501
5599
  }
5600
+ // ── Chat mode ───────────────────────────────────────────────────────
5601
+ switchMode(event) {
5602
+ const mode = event.currentTarget.dataset.mode === "chat" ? "chat" : "command";
5603
+ this.setMode(mode);
5604
+ if (mode === "command")
5605
+ this.filter();
5606
+ this.inputTarget.focus();
5607
+ }
5608
+ useStarter(event) {
5609
+ this.inputTarget.value = event.currentTarget.textContent?.trim() || "";
5610
+ this.send();
5611
+ }
5612
+ resetChat() {
5613
+ if (this.streamAbort)
5614
+ this.streamAbort.abort();
5615
+ this.messages = [];
5616
+ if (this.hasTranscriptTarget)
5617
+ this.transcriptTarget.innerHTML = "";
5618
+ if (this.hasStartersTarget)
5619
+ this.startersTarget.hidden = false;
5620
+ this.setBusy(false);
5621
+ this.setMode("command");
5622
+ }
5623
+ setMode(mode) {
5624
+ if (!this.chatEnabledValue)
5625
+ return;
5626
+ this.mode = mode;
5627
+ const chat = mode === "chat";
5628
+ this.element.classList.toggle("chat-active", chat);
5629
+ if (this.hasModesTarget) {
5630
+ this.modesTarget.querySelectorAll(".command-palette-mode").forEach((el) => {
5631
+ el.setAttribute("aria-pressed", String(el.dataset.mode === mode));
5632
+ });
5633
+ }
5634
+ if (this.hasChatTarget)
5635
+ this.chatTarget.hidden = !chat;
5636
+ if (chat) {
5637
+ this.emptyTarget.hidden = true;
5638
+ if (this.hasListTarget)
5639
+ this.listTarget.hidden = true;
5640
+ if (this.hasTurboListTarget)
5641
+ this.turboListTarget.hidden = true;
5642
+ this.inputTarget.placeholder = this.chatPlaceholderValue;
5643
+ } else {
5644
+ this.inputTarget.placeholder = this.commandPlaceholder;
5645
+ if (this.hasTurboListTarget)
5646
+ this.turboListTarget.hidden = false;
5647
+ }
5648
+ }
5649
+ stop() {
5650
+ if (this.streamAbort)
5651
+ this.streamAbort.abort();
5652
+ }
5653
+ send() {
5654
+ if (this.streamAbort)
5655
+ return;
5656
+ const text = this.inputTarget.value.trim();
5657
+ if (!text)
5658
+ return;
5659
+ this.appendMessage("user", text);
5660
+ this.messages.push({ role: "user", content: text });
5661
+ this.inputTarget.value = "";
5662
+ if (this.hasStartersTarget)
5663
+ this.startersTarget.hidden = true;
5664
+ void this.streamReply();
5665
+ }
5666
+ async streamReply() {
5667
+ const message = this.appendMessage("assistant", "");
5668
+ const bubble = message.querySelector(".command-palette-message-bubble");
5669
+ this.setBusy(true);
5670
+ this.streamAbort = new AbortController();
5671
+ let reply = "";
5672
+ try {
5673
+ const response = await fetch(this.chatRequestUrl(), {
5674
+ method: "POST",
5675
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
5676
+ body: JSON.stringify(this.chatRequestBody()),
5677
+ signal: this.streamAbort.signal
5678
+ });
5679
+ if (!response.ok || !response.body)
5680
+ throw new Error(`Chat request failed (${response.status})`);
5681
+ const reader = response.body.getReader();
5682
+ const decoder = new TextDecoder();
5683
+ let buffer = "";
5684
+ for (; ; ) {
5685
+ const { value, done } = await reader.read();
5686
+ if (done)
5687
+ break;
5688
+ buffer += decoder.decode(value, { stream: true });
5689
+ const events = buffer.split("\n\n");
5690
+ buffer = events.pop() || "";
5691
+ for (const event of events) {
5692
+ const data = this.parseSseData(event);
5693
+ if (!data)
5694
+ continue;
5695
+ const delta = data.choices?.[0]?.delta?.content;
5696
+ if (delta) {
5697
+ reply += delta;
5698
+ bubble.innerHTML = renderMarkdown(reply);
5699
+ this.scrollTranscript();
5700
+ } else if (data.midwest) {
5701
+ this.handleAgentEvent(data.midwest);
5702
+ }
5703
+ }
5704
+ }
5705
+ this.messages.push({ role: "assistant", content: reply });
5706
+ } catch (error) {
5707
+ if (error.name === "AbortError") {
5708
+ if (reply)
5709
+ this.messages.push({ role: "assistant", content: reply });
5710
+ } else {
5711
+ message.classList.add("is-error");
5712
+ bubble.textContent = "Something went wrong. Please try again.";
5713
+ }
5714
+ } finally {
5715
+ this.streamAbort = null;
5716
+ this.setBusy(false);
5717
+ this.scrollTranscript();
5718
+ }
5719
+ }
5720
+ parseSseData(event) {
5721
+ const data = event.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("");
5722
+ if (!data || data === "[DONE]")
5723
+ return null;
5724
+ try {
5725
+ return JSON.parse(data);
5726
+ } catch {
5727
+ return null;
5728
+ }
5729
+ }
5730
+ // Agentic events: the server streams a tool "action row" and/or a client
5731
+ // directive (navigate / run a palette command). Directives reuse the same
5732
+ // execution path as manual selection.
5733
+ handleAgentEvent(event) {
5734
+ if (event.type === "tool") {
5735
+ this.appendActionRow(event);
5736
+ } else if (event.type === "directive" && event.directive) {
5737
+ this.performDirective(event.directive);
5738
+ }
5739
+ }
5740
+ // Action rows live in the current assistant message's card footer.
5741
+ appendActionRow(event) {
5742
+ if (!this.hasActionTemplateTarget)
5743
+ return;
5744
+ const footer = this.currentActionFooter();
5745
+ if (!footer)
5746
+ return;
5747
+ const action = cloneTemplate(this.actionTemplateTarget);
5748
+ const label = action.querySelector(".command-palette-action-label");
5749
+ if (label)
5750
+ label.textContent = event.label || "Action";
5751
+ footer.hidden = false;
5752
+ footer.appendChild(action);
5753
+ this.scrollTranscript();
5754
+ }
5755
+ currentActionFooter() {
5756
+ const messages = this.transcriptTarget.querySelectorAll(
5757
+ ".command-palette-message.role-assistant"
5758
+ );
5759
+ const last = messages[messages.length - 1];
5760
+ return last ? last.querySelector(".command-palette-action-footer") : null;
5761
+ }
5762
+ performDirective(directive) {
5763
+ if (directive.url) {
5764
+ this.visit(directive.url, directive.method);
5765
+ } else if (directive.action) {
5766
+ this.element.dispatchEvent(
5767
+ new CustomEvent("midwest-command-palette:action", {
5768
+ bubbles: true,
5769
+ detail: { action: directive.action }
5770
+ })
5771
+ );
5772
+ }
5773
+ }
5774
+ chatRequestUrl() {
5775
+ return this.chatModeValue === "proxy" ? this.chatEndpointValue : this.chatUrlValue;
5776
+ }
5777
+ chatRequestBody() {
5778
+ const body = { messages: this.messages, stream: true };
5779
+ if (this.chatModeValue !== "proxy" && this.chatModelValue)
5780
+ body.model = this.chatModelValue;
5781
+ if (this.chatModeValue === "proxy" && this.chatAgenticValue) {
5782
+ body.commands = this.allItems.map((item) => ({
5783
+ label: item.label,
5784
+ description: item.description,
5785
+ url: item.url,
5786
+ action: item.action,
5787
+ method: item.method,
5788
+ icon: item.icon
5789
+ }));
5790
+ }
5791
+ return body;
5792
+ }
5793
+ setBusy(busy) {
5794
+ this.element.classList.toggle("chat-busy", busy);
5795
+ if (this.hasStopTarget)
5796
+ this.stopTarget.hidden = !busy;
5797
+ if (this.hasTranscriptTarget)
5798
+ this.transcriptTarget.setAttribute("aria-busy", String(busy));
5799
+ }
5800
+ scrollTranscript() {
5801
+ if (this.hasTranscriptTarget)
5802
+ this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight;
5803
+ }
5804
+ appendMessage(role, text) {
5805
+ const li = cloneTemplate(this.messageTemplateTarget);
5806
+ li.classList.add(`role-${role}`);
5807
+ if (role === "user")
5808
+ li.classList.add("is-end");
5809
+ const roleEl = li.querySelector(".command-palette-message-role");
5810
+ if (roleEl)
5811
+ roleEl.textContent = role === "user" ? "You" : "Assistant";
5812
+ const bubble = li.querySelector(".command-palette-message-bubble");
5813
+ if (text)
5814
+ bubble.textContent = text;
5815
+ this.transcriptTarget.appendChild(li);
5816
+ this.scrollTranscript();
5817
+ return li;
5818
+ }
5502
5819
  // ── Remote search ───────────────────────────────────────────────────
5503
5820
  fetchRemote(query) {
5504
5821
  if (this.fetchAbort)