From f73120138e9b4347555ed01d2c27ceab293cd7c5 Mon Sep 17 00:00:00 2001 From: Pi on Code Agent <313040514+pi-on-code-agent[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:28:00 +0000 Subject: [PATCH 1/2] fix: cap session tab title length --- src/tab-label.ts | 22 ++++++++++++++++++ src/test/tab-label.test.ts | 46 ++++++++++++++++++++++++++++++++++++++ src/webview-panel.ts | 6 +++-- 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/tab-label.ts create mode 100644 src/test/tab-label.test.ts diff --git a/src/tab-label.ts b/src/tab-label.ts new file mode 100644 index 0000000..7478360 --- /dev/null +++ b/src/tab-label.ts @@ -0,0 +1,22 @@ +/** + * Visible label used on editor tabs for a session. Tabs that carry the full + * session title grow without bound, pushing sibling tabs out of view, so the + * displayed label is capped deterministically. The cap is applied in exactly + * the same way for every tab state so activation never changes the length. + */ +export const DEFAULT_TAB_LABEL_MAX = 28; + +function sliceCodePoints(value: string, maxLength: number): string { + const codePoints = Array.from(value); + return codePoints.slice(0, maxLength).join(""); +} + +/** Collapse whitespace, cap by code points, and add an ellipsis when cut. */ +export function limitTabLabel( + label: string, + maxLength: number = DEFAULT_TAB_LABEL_MAX, +): string { + const compact = label.replace(/\s+/g, " ").trim(); + if (compact.length <= maxLength) { return compact; } + return `${sliceCodePoints(compact, maxLength).replace(/[\s.…]+$/u, "")}…`; +} diff --git a/src/test/tab-label.test.ts b/src/test/tab-label.test.ts new file mode 100644 index 0000000..068f429 --- /dev/null +++ b/src/test/tab-label.test.ts @@ -0,0 +1,46 @@ +import * as assert from "node:assert"; +import { readFileSync } from "node:fs"; +import { limitTabLabel } from "../tab-label.js"; + +suite("Session tab label limits", () => { + test("keeps short labels unchanged", () => { + assert.strictEqual(limitTabLabel("Fix sidebar refresh"), "Fix sidebar refresh"); + assert.strictEqual(limitTabLabel("Pi"), "Pi"); + assert.strictEqual(limitTabLabel(""), ""); + }); + + test("truncates long labels to the configured maximum with an ellipsis", () => { + const label = "a very long session title that would push sibling tabs out of view"; + const limited = limitTabLabel(label, 28); + assert.strictEqual(Array.from(limited).length, 29); + assert.ok(limited.endsWith("…")); + assert.strictEqual(Array.from(limited.slice(0, -1)).length, 28); + }); + + test("counts code points instead of UTF-16 code units", () => { + const label = "πŸ€–".repeat(40) + " title"; + const limited = limitTabLabel(label, 28); + assert.strictEqual(Array.from(limited).length, 29); + assert.ok(!limited.includes("\uFFFD"), "must not split surrogate pairs"); + }); + + test("collapses whitespace before applying the cap", () => { + const label = "ζ ‡ι’˜ 很 ι•Ώ 占用 η©Ίι—΄ ".repeat(4) + "ζ ‡ι’˜ζ "; + const limited = limitTabLabel(label, 28); + assert.ok(!limited.includes(" "), "consecutive spaces must be collapsed"); + assert.ok(limited.endsWith("…")); + }); + + test("caps every tab state through the shared title builder", () => { + const source = readFileSync( + new URL("../../src/webview-panel.ts", import.meta.url), + "utf8", + ); + assert.match(source, /const label = limitTabLabel\(this\._tabSummary \?\? "Pi"\);/); + assert.match( + source, + /this\.panel\.title = \(this\._tabStreaming \? "\\u25CF " : "\\u25CB "\) \+ label;/, + ); + assert.match(source, /import \{ limitTabLabel \} from "\.\/tab-label\.js";/); + }); +}); diff --git a/src/webview-panel.ts b/src/webview-panel.ts index a9b2eab..61d6b20 100644 --- a/src/webview-panel.ts +++ b/src/webview-panel.ts @@ -11,6 +11,7 @@ import { mergeInitialHistoryEvents } from "./history-event-sync.js"; import { SessionCapabilitySnapshot } from "./capability-snapshot.js"; import { piError } from "./logger.js"; import { getWorkspaceCwd } from "./workspace-context.js"; +import { limitTabLabel } from "./tab-label.js"; import type { PiService } from "./pi-service.js"; import type { PiServiceEvent } from "./types.js"; import { @@ -860,8 +861,9 @@ export class PiWebviewPanel { return; } - const label = this._tabSummary ?? "Pi"; - // Bullet prefix: ● busy, β—‹ idle β€” consistent with status bar + const label = limitTabLabel(this._tabSummary ?? "Pi"); + // Bullet prefix: ● busy, β—‹ idle β€” consistent with status bar. The cap is + // applied before the prefix so streaming and idle tabs keep equal length. this.panel.title = (this._tabStreaming ? "\u25CF " : "\u25CB ") + label; } From 4dd56a77be5a482b00b1e26803774fb2ecbe80d8 Mon Sep 17 00:00:00 2001 From: auchan Date: Sat, 5 Sep 2026 23:40:57 +0800 Subject: [PATCH 2/2] fix: count tab label cap by code points --- src/tab-label.ts | 2 +- src/test/tab-label.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tab-label.ts b/src/tab-label.ts index 7478360..ef1b56f 100644 --- a/src/tab-label.ts +++ b/src/tab-label.ts @@ -17,6 +17,6 @@ export function limitTabLabel( maxLength: number = DEFAULT_TAB_LABEL_MAX, ): string { const compact = label.replace(/\s+/g, " ").trim(); - if (compact.length <= maxLength) { return compact; } + if (Array.from(compact).length <= maxLength) { return compact; } return `${sliceCodePoints(compact, maxLength).replace(/[\s.…]+$/u, "")}…`; } diff --git a/src/test/tab-label.test.ts b/src/test/tab-label.test.ts index 068f429..361b6a5 100644 --- a/src/test/tab-label.test.ts +++ b/src/test/tab-label.test.ts @@ -18,6 +18,9 @@ suite("Session tab label limits", () => { }); test("counts code points instead of UTF-16 code units", () => { + const exactLengthLabel = "πŸ€–".repeat(28); + assert.strictEqual(limitTabLabel(exactLengthLabel, 28), exactLengthLabel); + const label = "πŸ€–".repeat(40) + " title"; const limited = limitTabLabel(label, 28); assert.strictEqual(Array.from(limited).length, 29);