@firstpick/pi-package-webui 0.5.3 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@firstpick/pi-package-webui",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/Firstp1ck/npm-packages/tree/main/pi-package-webui#readme",
package/public/app.js CHANGED
@@ -516,6 +516,9 @@ const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220;
516
516
  const STREAM_OUTPUT_MIN_VISIBLE_MS = 900;
517
517
  const TOOL_LIVE_UPDATE_THROTTLE_MS = 80;
518
518
  const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider.";
519
+ const THINKING_FORMAT_OPEN_TAG_REGEX = /^<think\b[^>]*>/i;
520
+ const THINKING_FORMAT_CLOSE_TAG_REGEX = /<\/think\s*>/i;
521
+ const CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX = /^<\|([a-z][\w-]*)>/i;
519
522
  const TODO_PROGRESS_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)\]\s+.+$/;
520
523
  const TODO_PROGRESS_PARTIAL_LINE_REGEX = /^\s*(?:(?:[-*]|\d+[.)])\s*)?\[(?: |x|X|-)?\]?\s*.*$/;
521
524
  const CHAT_SCROLL_KEYS = new Set(["ArrowDown", "ArrowUp", "End", "Home", "PageDown", "PageUp", " "]);
@@ -13446,6 +13449,89 @@ function visibleThinkingText(text) {
13446
13449
  return value;
13447
13450
  }
13448
13451
 
13452
+ function escapeRegExp(value) {
13453
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13454
+ }
13455
+
13456
+ function isPartialThinkingFormatOpenTag(text) {
13457
+ const value = String(text || "").trimStart().toLowerCase();
13458
+ if (!value) return false;
13459
+ if ("<think>".startsWith(value)) return true;
13460
+ if (value === "<|" || /^<\|[a-z][\w-]*$/i.test(value)) return true;
13461
+ return /^<think\b[^>]*$/i.test(value);
13462
+ }
13463
+
13464
+ function stripPartialThinkingFormatClose(text, closeTag = "</think>") {
13465
+ const value = String(text || "");
13466
+ const lower = value.toLowerCase();
13467
+ const expected = String(closeTag || "").toLowerCase();
13468
+ const start = lower.lastIndexOf("<");
13469
+ if (start < 0) return value;
13470
+ const partial = lower.slice(start).trimEnd();
13471
+ return expected.startsWith(partial) ? value.slice(0, start) : value;
13472
+ }
13473
+
13474
+ function stripThinkingFormatOutputSeparator(text) {
13475
+ return String(text || "").replace(/^(?:[ \t]*\r?\n)+/, "").replace(/^[ \t]+/, "");
13476
+ }
13477
+
13478
+ function joinedThinkingFormatParts(parts) {
13479
+ return parts.map((part) => String(part || "")).filter((part) => part.length > 0).join("\n\n");
13480
+ }
13481
+
13482
+ function thinkingFormatOpenMatch(text) {
13483
+ const value = String(text || "");
13484
+ const think = THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
13485
+ if (think) return { raw: think[0], closeRegex: THINKING_FORMAT_CLOSE_TAG_REGEX, closeTag: "</think>" };
13486
+ const channel = CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX.exec(value);
13487
+ if (!channel) return null;
13488
+ const name = channel[1];
13489
+ return { raw: channel[0], closeRegex: new RegExp(`<${escapeRegExp(name)}\\|>`, "i"), closeTag: `<${name}|>` };
13490
+ }
13491
+
13492
+ function splitThinkingFormatText(text, { streaming = false } = {}) {
13493
+ let rest = String(text ?? "").trimStart();
13494
+ if (!rest) return null;
13495
+ if (!thinkingFormatOpenMatch(rest)) {
13496
+ return streaming && isPartialThinkingFormatOpenTag(rest)
13497
+ ? { hasThinkingFormat: true, thinkingText: "", finalText: "", complete: false }
13498
+ : null;
13499
+ }
13500
+
13501
+ const thinkingParts = [];
13502
+ let open = thinkingFormatOpenMatch(rest);
13503
+ while (open) {
13504
+ const afterOpen = rest.slice(open.raw.length);
13505
+ const close = open.closeRegex.exec(afterOpen);
13506
+ if (!close) {
13507
+ thinkingParts.push(streaming ? stripPartialThinkingFormatClose(afterOpen, open.closeTag) : afterOpen);
13508
+ return { hasThinkingFormat: true, thinkingText: joinedThinkingFormatParts(thinkingParts), finalText: "", complete: false };
13509
+ }
13510
+
13511
+ thinkingParts.push(afterOpen.slice(0, close.index));
13512
+ rest = afterOpen.slice(close.index + close[0].length);
13513
+ const next = rest.trimStart();
13514
+ open = thinkingFormatOpenMatch(next);
13515
+ if (open) {
13516
+ rest = next;
13517
+ continue;
13518
+ }
13519
+ break;
13520
+ }
13521
+
13522
+ return {
13523
+ hasThinkingFormat: true,
13524
+ thinkingText: joinedThinkingFormatParts(thinkingParts),
13525
+ finalText: stripThinkingFormatOutputSeparator(rest),
13526
+ complete: true,
13527
+ };
13528
+ }
13529
+
13530
+ function appendThinkingFormatDisplayMessages(displayMessages, base, parsed) {
13531
+ const thinking = visibleThinkingText(parsed?.thinkingText || "");
13532
+ if (thinking) displayMessages.push({ ...base, role: "thinking", title: "thinking", content: thinking, thinking });
13533
+ }
13534
+
13449
13535
  function isAssistantToolCallPart(part) {
13450
13536
  return !!(part && typeof part === "object" && (part.type === "toolCall" || part.toolCall));
13451
13537
  }
@@ -13495,6 +13581,13 @@ function assistantDisplayMessages(message) {
13495
13581
  const base = { timestamp: message.timestamp };
13496
13582
  const content = message.content;
13497
13583
  if (typeof content === "string") {
13584
+ const parsed = splitThinkingFormatText(content);
13585
+ if (parsed?.hasThinkingFormat) {
13586
+ const displayMessages = [];
13587
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
13588
+ if (parsed.finalText.trim()) displayMessages.push({ ...message, title: "final output", content: parsed.finalText });
13589
+ return displayMessages;
13590
+ }
13498
13591
  return content.trim() ? [{ ...message, title: "final output" }] : [];
13499
13592
  }
13500
13593
  if (!Array.isArray(content)) {
@@ -13518,6 +13611,16 @@ function assistantDisplayMessages(message) {
13518
13611
  displayMessages.push({ ...base, role: "toolCall", title: `tool call: ${toolName}`, toolName, toolCallId, arguments: args, content: args });
13519
13612
  continue;
13520
13613
  }
13614
+ const primitiveText = part !== undefined && part !== null && typeof part !== "object" ? String(part) : "";
13615
+ const textForThinkingFormat = primitiveText || (part && typeof part === "object" && (part.type === "text" || typeof part.text === "string") ? assistantTextPartText(part) || part.text : "");
13616
+ if (textForThinkingFormat) {
13617
+ const parsed = splitThinkingFormatText(textForThinkingFormat);
13618
+ if (parsed?.hasThinkingFormat) {
13619
+ appendThinkingFormatDisplayMessages(displayMessages, base, parsed);
13620
+ if (parsed.finalText.trim() && !assistantHasToolCallAfter(content, index)) finalParts.push(part && typeof part === "object" ? { ...part, type: "text", text: parsed.finalText } : { type: "text", text: parsed.finalText });
13621
+ continue;
13622
+ }
13623
+ }
13521
13624
  const finalPart = assistantFinalOutputPart(part);
13522
13625
  if (finalPart) {
13523
13626
  if (!assistantHasToolCallAfter(content, index)) finalParts.push(finalPart);
@@ -16683,6 +16786,12 @@ function removeStreamBubble() {
16683
16786
  renderRunIndicator({ scroll: false });
16684
16787
  }
16685
16788
 
16789
+ function streamRenderableAssistantText() {
16790
+ const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
16791
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
16792
+ return parsed?.hasThinkingFormat ? stripTodoProgressLines(parsed.finalText, { streaming: true }) : assistantText;
16793
+ }
16794
+
16686
16795
  function scheduleStreamBubbleHide() {
16687
16796
  if (!streamBubble) return;
16688
16797
  const visibleForMs = streamBubbleVisibleSince ? performance.now() - streamBubbleVisibleSince : STREAM_OUTPUT_MIN_VISIBLE_MS;
@@ -16690,16 +16799,27 @@ function scheduleStreamBubbleHide() {
16690
16799
  clearTimeout(streamBubbleHideTimer);
16691
16800
  streamBubbleHideTimer = setTimeout(() => {
16692
16801
  streamBubbleHideTimer = null;
16693
- if (stripTodoProgressLines(streamRawText, { streaming: true }) || !streamBubble) return;
16802
+ if (streamRenderableAssistantText() || !streamBubble) return;
16694
16803
  removeStreamBubble();
16695
16804
  }, delayMs);
16696
16805
  }
16697
16806
 
16807
+ function syncStreamingThinkingFormat(assistantText) {
16808
+ const parsed = splitThinkingFormatText(assistantText, { streaming: true });
16809
+ if (!parsed?.hasThinkingFormat) return null;
16810
+ const thinking = visibleThinkingText(parsed.thinkingText);
16811
+ if (thinking) setStreamingThinkingText(thinking);
16812
+ if (parsed.complete && streamThinkingBubble) streamThinkingBubble.classList.add("complete");
16813
+ return parsed;
16814
+ }
16815
+
16698
16816
  function renderStreamingAssistantText() {
16699
16817
  const assistantText = stripTodoProgressLines(streamRawText, { streaming: true });
16700
- if (assistantText) {
16818
+ const thinkingFormat = syncStreamingThinkingFormat(assistantText);
16819
+ const finalText = thinkingFormat?.hasThinkingFormat ? stripTodoProgressLines(thinkingFormat.finalText, { streaming: true }) : assistantText;
16820
+ if (finalText) {
16701
16821
  ensureStreamBubble();
16702
- renderStreamingMarkdown(streamText, assistantText);
16822
+ renderStreamingMarkdown(streamText, finalText);
16703
16823
  } else {
16704
16824
  scheduleStreamBubbleHide();
16705
16825
  }
@@ -16793,26 +16913,39 @@ function assistantStreamingMessage(event) {
16793
16913
  return partial?.role === "assistant" ? partial : null;
16794
16914
  }
16795
16915
 
16796
- function assistantTextFromMessage(message) {
16916
+ function assistantTextFromMessage(message, { streaming = false } = {}) {
16917
+ void streaming;
16797
16918
  const content = message?.content;
16798
16919
  if (typeof content === "string") return content;
16799
16920
  if (!Array.isArray(content)) return null;
16800
16921
  const parts = [];
16801
16922
  for (let index = 0; index < content.length; index += 1) {
16802
16923
  const part = content[index];
16803
- const text = assistantTextPartText(part);
16924
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
16804
16925
  if (text && !assistantHasToolCallAfter(content, index)) parts.push(text);
16805
16926
  }
16806
16927
  return parts.length ? parts.join("\n\n") : "";
16807
16928
  }
16808
16929
 
16809
- function assistantThinkingTextFromMessage(message) {
16930
+ function assistantThinkingTextFromMessage(message, { streaming = false } = {}) {
16810
16931
  const content = message?.content;
16932
+ if (typeof content === "string") {
16933
+ const parsed = splitThinkingFormatText(content, { streaming });
16934
+ return parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : null;
16935
+ }
16811
16936
  if (!Array.isArray(content)) return null;
16812
- const parts = content
16813
- .filter((part) => part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string"))
16814
- .map((part) => visibleThinkingText(assistantThinkingText(part)))
16815
- .filter((text) => text.trim());
16937
+ const parts = [];
16938
+ for (const part of content) {
16939
+ if (part && typeof part === "object" && (part.type === "thinking" || typeof part.thinking === "string")) {
16940
+ const thinking = visibleThinkingText(assistantThinkingText(part));
16941
+ if (thinking.trim()) parts.push(thinking);
16942
+ continue;
16943
+ }
16944
+ const text = assistantTextPartText(part) || (part && typeof part === "object" && typeof part.text === "string" ? part.text : part !== undefined && part !== null && typeof part !== "object" ? String(part) : "");
16945
+ const parsed = splitThinkingFormatText(text, { streaming });
16946
+ const thinking = parsed?.hasThinkingFormat ? visibleThinkingText(parsed.thinkingText) : "";
16947
+ if (thinking.trim()) parts.push(thinking);
16948
+ }
16816
16949
  return parts.length ? parts.join("\n\n") : "";
16817
16950
  }
16818
16951
 
@@ -16826,7 +16959,7 @@ function setStreamingThinkingText(text) {
16826
16959
 
16827
16960
  function syncStreamingThinkingFromMessage(event, { placeholder = "" } = {}) {
16828
16961
  if (!thinkingOutputVisible) return true;
16829
- const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event));
16962
+ const text = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16830
16963
  if (text === null) return false;
16831
16964
  return setStreamingThinkingText(text || placeholder);
16832
16965
  }
@@ -16848,13 +16981,13 @@ function handleMessageUpdate(event) {
16848
16981
  }
16849
16982
  scrollChatToBottom();
16850
16983
  } else if (update.type === "thinking_end") {
16851
- const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event)) || thinkingDeltaText(update);
16984
+ const finalThinking = assistantThinkingTextFromMessage(assistantStreamingMessage(event), { streaming: true }) || thinkingDeltaText(update);
16852
16985
  if (finalThinking) setStreamingThinkingText(finalThinking);
16853
16986
  streamThinkingBubble?.classList.add("complete");
16854
16987
  setRunIndicatorActivity("Finished thinking; waiting for the next output or action…", { scroll: false });
16855
16988
  } else if (update.type === "text_delta" || update.type === "text_end") {
16856
16989
  const delta = update.type === "text_delta" ? update.delta || "" : "";
16857
- const partialText = assistantTextFromMessage(assistantStreamingMessage(event));
16990
+ const partialText = assistantTextFromMessage(assistantStreamingMessage(event), { streaming: true });
16858
16991
  if (typeof partialText === "string") streamRawText = partialText;
16859
16992
  else if (update.type === "text_end" && typeof update.content === "string") streamRawText = update.content;
16860
16993
  else streamRawText += delta;
@@ -747,6 +747,8 @@ assert.match(app, /appendText\(preview, toolResultPreviewText\(message, 10\), "c
747
747
  assert.match(app, /function assistantDisplayMessages\(message\)/, "assistant history should split thinking and tool-call parts out of the final Assistant output card");
748
748
  assert.match(app, /function assistantHasToolCallAfter\(content, index\)/, "assistant text that precedes a tool call should be detectable and suppressible");
749
749
  assert.match(app, /if \(!assistantHasToolCallAfter\(content, index\)\) finalParts\.push\(finalPart\);/, "assistant history should not render pre-tool-call assistant text as final output");
750
+ assert.match(app, /typeof content === "string"[\s\S]*?splitThinkingFormatText\(content\)[\s\S]*?content: parsed\.finalText/, "assistant string messages with tagged <think> output should render final text separately");
751
+ assert.match(app, /const textForThinkingFormat[\s\S]*?splitThinkingFormatText\(textForThinkingFormat\)[\s\S]*?appendThinkingFormatDisplayMessages\(displayMessages, base, parsed\)[\s\S]*?finalParts\.push/, "assistant text parts with tagged <think> output should split into thinking and final-output cards");
750
752
  assert.match(app, /return content\.trim\(\) \? \[\{ \.\.\.message, title: "final output" \}\] : \[\]/, "assistant messages with stripped empty text should not render empty final-output cards");
751
753
  assert.match(app, /function isEmptyAssistantTextPart\(part\)[\s\S]*?part\.type === "text"[\s\S]*?!assistantTextPartText\(part\)\.trim\(\)/, "empty assistant text parts should be recognized as skippable provider metadata");
752
754
  assert.match(app, /if \(isEmptyAssistantTextPart\(part\)\) continue;/, "empty assistant text parts should not render as assistant-event cards");
@@ -754,6 +756,10 @@ assert.match(app, /function assistantFinalOutputPart\(part\)[\s\S]*?if \(part\.t
754
756
  assert.match(app, /\["assistant", "toolExecution"\]\.includes\(transcriptMessage\.role\) \? messageIndex : -1/, "final Assistant output and paired tool action cards should keep the source message index for feedback");
755
757
  assert.match(app, /function ensureStreamingThinkingBubble\(\)[\s\S]*if \(!thinkingOutputVisible\) return false/, "live thinking should respect the show/hide thinking-output toggle");
756
758
  assert.match(app, /const UNEXPOSED_THINKING_TEXT = "No thinking content was exposed by the provider\."/, "frontend should name the provider no-thinking placeholder for suppression");
759
+ assert.match(app, /THINKING_FORMAT_OPEN_TAG_REGEX/, "frontend should recognize tagged <think> provider output");
760
+ assert.match(app, /CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX = \/\^<\\\|\(\[a-z\]\[\\w-\]\*\)>\/i/, "frontend should recognize tagged <|channel> provider output");
761
+ assert.match(app, /function thinkingFormatOpenMatch\(text\)[\s\S]*?CHANNEL_THINKING_FORMAT_OPEN_TAG_REGEX[\s\S]*?closeRegex: new RegExp\(`<\$\{escapeRegExp\(name\)\}\\\\\|>`/, "channel-style tagged output should create a matching <channel|> close delimiter");
762
+ assert.match(app, /function splitThinkingFormatText\(text, \{ streaming = false \} = \{\}\)[\s\S]*?thinkingFormatOpenMatch\(rest\)[\s\S]*?finalText: stripThinkingFormatOutputSeparator\(rest\)/, "tagged thinking output should split thinking text from final response text");
757
763
  assert.match(app, /function visibleThinkingText\(text\)[\s\S]*?trimmed === UNEXPOSED_THINKING_TEXT[\s\S]*?return "";/, "provider no-thinking placeholders should normalize to empty thinking output");
758
764
  assert.match(app, /if \(isThinkingPart\) \{[\s\S]*?visibleThinkingText\(assistantThinkingText\(part\)\)[\s\S]*?if \(thinking\) displayMessages\.push/, "assistant transcript splitting should skip empty or unexposed thinking parts");
759
765
  assert.match(app, /message\.role === "thinking"[\s\S]*?visibleThinkingText\(message\.thinking \|\| textFromContent\(message\.content\)\)[\s\S]*?if \(thinkingOutputVisible && thinkingText\) appendText\(body, thinkingText, "thinking-text"\);/, "thinking cards should suppress empty and provider no-thinking placeholder output");
@@ -766,15 +772,17 @@ assert.match(app, /function thinkingDeltaText\(update\) \{[\s\S]*?return visible
766
772
  assert.match(app, /const THINKING_VISIBILITY_STORAGE_KEY = "pi-webui-thinking-visible"/, "thinking visibility should persist in browser storage");
767
773
  assert.match(app, /function setThinkingOutputVisible\(visible[\s\S]*renderAllMessages\(\{ preserveScroll: true \}\)/, "thinking visibility changes should immediately re-render the transcript");
768
774
  assert.match(app, /function assistantStreamingMessage\(event\)/, "live streaming should read the authoritative partial assistant message from RPC events like the TUI");
769
- assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\)\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
775
+ assert.match(app, /assistantThinkingTextFromMessage\(assistantStreamingMessage\(event\), \{ streaming: true \}\) \|\| thinkingDeltaText\(update\)/, "live thinking end should replace deltas with the final partial-message thinking content");
770
776
  assert.match(app, /if \(typeof partialText === "string"\) streamRawText = partialText;/, "live assistant text should synchronize from partial messages instead of relying only on deltas");
771
777
  assert.match(app, /const TODO_PROGRESS_LINE_REGEX = /, "frontend should recognize live todo progress lines that will be moved into the todo widget");
772
778
  assert.match(app, /function stripTodoProgressLines\(text, \{ streaming = false \} = \{\}\)/, "live Assistant output should strip todo-progress lines before rendering final-output text");
773
779
  assert.match(app, /function renderStreamingAssistantText\(\)[\s\S]*?const assistantText = stripTodoProgressLines\(streamRawText, \{ streaming: true \}\)/, "streamed Assistant text should classify from accumulated output without flashing partial todo-progress lines");
780
+ assert.match(app, /function syncStreamingThinkingFormat\(assistantText\)[\s\S]*?splitThinkingFormatText\(assistantText, \{ streaming: true \}\)[\s\S]*?setStreamingThinkingText\(thinking\)/, "tagged <think> streaming output should update the live thinking card instead of flashing raw tags");
781
+ assert.match(app, /const finalText = thinkingFormat\?\.hasThinkingFormat \? stripTodoProgressLines\(thinkingFormat\.finalText, \{ streaming: true \}\) : assistantText;/, "tagged <think> streaming output should render only final response text in the Assistant card");
774
782
  assert.match(app, /const STREAM_OUTPUT_HIDE_DELAY_MS = 300/, "stream output hiding should be debounced to prevent rapid flicker");
775
783
  assert.match(app, /const STREAM_OUTPUT_TOOLCALL_GUARD_MS = 220/, "live assistant text should be briefly guarded so pre-tool-call text can be suppressed");
776
784
  assert.match(app, /function scheduleStreamBubbleHide\([\s\S]*?STREAM_OUTPUT_MIN_VISIBLE_MS/, "stream output cards should observe a minimum visible duration before hiding");
777
- assert.match(app, /if \(assistantText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, assistantText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
785
+ assert.match(app, /if \(finalText\) \{[\s\S]*?renderStreamingMarkdown\(streamText, finalText\);[\s\S]*?\} else \{\n\s+scheduleStreamBubbleHide\(\);/, "empty filtered stream output should schedule hide while visible stream output renders as Markdown");
778
786
  assert.match(app, /if \(streamToolCallSeen \|\| streamBubble\) renderStreamingAssistantText\(\);\n\s+else scheduleStreamingAssistantTextRender\(\);/, "live assistant text should wait briefly before showing unless it is already visible or follows a tool call");
779
787
  assert.match(app, /streamToolCallSeen = true;\n\s+suppressStreamingAssistantTextBeforeToolCall\(\);/, "tool-call starts should remove pending assistant text from the live transcript");
780
788
  assert.match(app, /const created = appendMessage\(\{ role: "assistant", title: "final output"/, "live Assistant cards should be created only for final output text without a noisy Assistant label");