pi-ui-extend 0.1.38 → 0.1.39

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.
Files changed (43) hide show
  1. package/dist/app/app.d.ts +0 -1
  2. package/dist/app/app.js +22 -21
  3. package/dist/app/input/input-controller.d.ts +1 -0
  4. package/dist/app/input/input-controller.js +40 -12
  5. package/dist/app/model/model-usage-status.js +4 -2
  6. package/dist/app/session/request-history.js +2 -0
  7. package/dist/app/session/session-event-controller.d.ts +13 -0
  8. package/dist/app/session/session-event-controller.js +27 -0
  9. package/dist/app/session/tabs-controller.d.ts +8 -0
  10. package/dist/app/session/tabs-controller.js +37 -6
  11. package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
  12. package/dist/app/workspace/workspace-actions-controller.js +2 -1
  13. package/dist/bundled-extensions/terminal-bell/index.js +1 -1
  14. package/dist/markdown-format.js +14 -25
  15. package/dist/terminal-width.d.ts +14 -0
  16. package/dist/terminal-width.js +31 -2
  17. package/dist/theme.js +2 -2
  18. package/external/pi-tools-suite/README.md +34 -9
  19. package/external/pi-tools-suite/package.json +3 -3
  20. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +35 -21
  21. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  22. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +2 -2
  23. package/external/pi-tools-suite/src/async-subagents/core/config.ts +70 -12
  24. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +1 -1
  25. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +1 -1
  26. package/external/pi-tools-suite/src/async-subagents/core/types.ts +1 -1
  27. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -6
  28. package/external/pi-tools-suite/src/async-subagents/lib.ts +1 -1
  29. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +4 -2
  30. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
  31. package/external/pi-tools-suite/src/{glm-coding-discipline → coding-discipline}/index.ts +17 -8
  32. package/external/pi-tools-suite/src/config.ts +1 -1
  33. package/external/pi-tools-suite/src/dcp/auto-compress.ts +368 -0
  34. package/external/pi-tools-suite/src/dcp/compress-tool.ts +3 -0
  35. package/external/pi-tools-suite/src/dcp/config.ts +23 -0
  36. package/external/pi-tools-suite/src/dcp/index.ts +112 -7
  37. package/external/pi-tools-suite/src/dcp/prompts.ts +8 -0
  38. package/external/pi-tools-suite/src/dcp/state.ts +41 -0
  39. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +30 -22
  40. package/external/pi-tools-suite/src/index.ts +2 -1
  41. package/external/pi-tools-suite/src/session-name/index.ts +37 -0
  42. package/external/pi-tools-suite/src/tool-descriptions.ts +16 -4
  43. package/package.json +4 -4
package/dist/app/app.d.ts CHANGED
@@ -94,7 +94,6 @@ export declare class PiUiExtendApp {
94
94
  private loadSessionHistory;
95
95
  private openSearchResultInNewTab;
96
96
  private scrollToUserMessageJumpTarget;
97
- private findUserEntryBySessionEntryId;
98
97
  private findUserEntryByJumpText;
99
98
  private loadSessionHistoryAsync;
100
99
  private handleSessionEvent;
package/dist/app/app.js CHANGED
@@ -360,6 +360,7 @@ export class PiUiExtendApp {
360
360
  });
361
361
  this.workspaceActions = new AppWorkspaceActionsController({
362
362
  entries: this.entries,
363
+ allEntries: () => this.sessionEvents.allEntries(),
363
364
  runtime: () => this.runtime,
364
365
  awaitCurrentSessionExtensions: (runtime) => this.awaitCurrentSessionExtensions(runtime),
365
366
  findUserEntry: (entryId) => this.findUserEntry(entryId),
@@ -999,39 +1000,39 @@ export class PiUiExtendApp {
999
1000
  return true;
1000
1001
  this.workspaceActions.syncUserSessionEntryMetadata();
1001
1002
  if (target.sessionEntryId) {
1002
- let entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
1003
- while (!entry && this.sessionEvents.hasOlderSessionHistory() && !this.sessionEvents.isLoadingOlderSessionHistory()) {
1004
- const loaded = await this.sessionEvents.loadOlderSessionHistory({ render: false });
1005
- if (!loaded)
1006
- break;
1007
- entry = this.findUserEntryBySessionEntryId(target.sessionEntryId);
1008
- }
1009
- if (entry && this.scrollController.scrollToConversationEntry(entry.id))
1003
+ const entryId = this.sessionEvents.revealHistoryEntryForSessionEntryId(target.sessionEntryId);
1004
+ if (entryId && this.scrollController.scrollToConversationEntry(entryId))
1010
1005
  return true;
1011
1006
  }
1012
1007
  const fallbackEntry = this.findUserEntryByJumpText(target);
1013
1008
  return fallbackEntry ? this.scrollController.scrollToConversationEntry(fallbackEntry.id) : false;
1014
1009
  }
1015
- findUserEntryBySessionEntryId(sessionEntryId) {
1016
- return this.entries.find((entry) => entry.kind === "user" && entry.sessionEntryId === sessionEntryId);
1017
- }
1018
1010
  findUserEntryByJumpText(target) {
1019
1011
  if (!target.text)
1020
1012
  return undefined;
1021
- const userEntries = this.entries.filter((entry) => entry.kind === "user");
1013
+ const userEntries = this.sessionEvents.allEntries().filter((entry) => entry.kind === "user");
1014
+ let matched;
1022
1015
  if (target.userIndex !== undefined && target.userCount !== undefined) {
1023
- const visibleIndex = target.userIndex - (target.userCount - userEntries.length);
1024
- const entry = userEntries[visibleIndex];
1016
+ const entry = userEntries[target.userIndex];
1025
1017
  if (entry && normalizeJumpTargetText(entry.text) === normalizeJumpTargetText(target.text))
1026
- return entry;
1018
+ matched = entry;
1019
+ }
1020
+ if (!matched) {
1021
+ const normalizedTargetText = normalizeJumpTargetText(target.text);
1022
+ for (let index = userEntries.length - 1; index >= 0; index -= 1) {
1023
+ const entry = userEntries[index];
1024
+ if (entry && normalizeJumpTargetText(entry.text) === normalizedTargetText) {
1025
+ matched = entry;
1026
+ break;
1027
+ }
1028
+ }
1027
1029
  }
1028
- const normalizedTargetText = normalizeJumpTargetText(target.text);
1029
- for (let index = userEntries.length - 1; index >= 0; index -= 1) {
1030
- const entry = userEntries[index];
1031
- if (entry && normalizeJumpTargetText(entry.text) === normalizedTargetText)
1032
- return entry;
1030
+ if (matched?.sessionEntryId) {
1031
+ // Shift the sliding window onto the matched entry so it is present in the
1032
+ // viewport for the subsequent scrollToConversationEntry call.
1033
+ this.sessionEvents.revealHistoryEntryForSessionEntryId(matched.sessionEntryId);
1033
1034
  }
1034
- return undefined;
1035
+ return matched;
1035
1036
  }
1036
1037
  async loadSessionHistoryAsync(options) {
1037
1038
  return this.sessionEvents.loadSessionHistoryAsync(options);
@@ -35,6 +35,7 @@ export declare class AppInputController {
35
35
  private readonly pasteHandler;
36
36
  constructor(host: InputControllerHost);
37
37
  handleChunk(chunk: Buffer): void;
38
+ private consumeBufferedSharedEditorInput;
38
39
  private consumeSharedEditorInput;
39
40
  private drainInputBuffer;
40
41
  private consumeBracketedPastePayload;
@@ -12,13 +12,23 @@ export class AppInputController {
12
12
  }
13
13
  handleChunk(chunk) {
14
14
  let data = chunk.toString("utf8");
15
+ const bufferedSharedEditorInput = this.consumeBufferedSharedEditorInput(data);
16
+ if (bufferedSharedEditorInput.kind === "consumed" || bufferedSharedEditorInput.kind === "pending")
17
+ return;
18
+ if (bufferedSharedEditorInput.kind === "passthrough")
19
+ data = bufferedSharedEditorInput.data;
15
20
  if (this.inputBuffer.startsWith("\x1b[<") || data.startsWith("\x1b[<")) {
16
21
  this.inputBuffer += data;
17
22
  this.drainInputBuffer();
18
23
  return;
19
24
  }
20
- if (this.consumeSharedEditorInput(data))
25
+ const sharedEditorInput = this.consumeSharedEditorInput(data);
26
+ if (sharedEditorInput === "consumed")
27
+ return;
28
+ if (sharedEditorInput === "pending") {
29
+ this.inputBuffer = data;
21
30
  return;
31
+ }
22
32
  const extensionInput = this.host.handleExtensionTerminalInput(data);
23
33
  if (extensionInput.consume)
24
34
  return;
@@ -29,41 +39,59 @@ export class AppInputController {
29
39
  this.inputBuffer += data;
30
40
  this.drainInputBuffer();
31
41
  }
42
+ consumeBufferedSharedEditorInput(data) {
43
+ if (this.host.extensionInputUsesEditor?.() !== true)
44
+ return { kind: "none" };
45
+ if (this.inputBuffer.length === 0)
46
+ return { kind: "none" };
47
+ const buffered = `${this.inputBuffer}${data}`;
48
+ const result = this.consumeSharedEditorInput(buffered);
49
+ if (result === "pending") {
50
+ this.inputBuffer = buffered;
51
+ return { kind: "pending" };
52
+ }
53
+ this.inputBuffer = "";
54
+ if (result === "consumed")
55
+ return { kind: "consumed" };
56
+ return { kind: "passthrough", data: buffered };
57
+ }
32
58
  consumeSharedEditorInput(data) {
33
59
  if (this.host.extensionInputUsesEditor?.() !== true)
34
- return false;
60
+ return "none";
35
61
  if (this.host.inputEditor.isInBracketedPaste)
36
- return false;
62
+ return "none";
37
63
  if (data === "\n") {
38
64
  this.insertInputNewline();
39
- return true;
65
+ return "consumed";
40
66
  }
41
67
  if (data === "\r" && this.isShiftPressed()) {
42
68
  this.insertInputNewline();
43
- return true;
69
+ return "consumed";
44
70
  }
45
71
  if (SHIFT_ENTER_ESCAPE_SEQUENCES.includes(data)) {
46
72
  this.insertInputNewline();
47
- return true;
73
+ return "consumed";
48
74
  }
49
75
  if (data === "\x16") {
50
76
  void this.pasteHandler.handleClipboardImagePaste();
51
- return true;
77
+ return "consumed";
52
78
  }
53
79
  const modifiedKey = parseTerminalModifiedKeySequence(data);
80
+ if (modifiedKey.kind === "pending")
81
+ return "pending";
54
82
  if (modifiedKey.kind !== "key")
55
- return false;
83
+ return "none";
56
84
  if (terminalKeyShouldIgnore(modifiedKey.key))
57
- return true;
85
+ return "consumed";
58
86
  if (terminalKeyIsShiftEnter(modifiedKey.key)) {
59
87
  this.insertInputNewline();
60
- return true;
88
+ return "consumed";
61
89
  }
62
90
  if (terminalKeyIsClipboardImagePaste(modifiedKey.key)) {
63
91
  void this.pasteHandler.handleClipboardImagePaste();
64
- return true;
92
+ return "consumed";
65
93
  }
66
- return false;
94
+ return "none";
67
95
  }
68
96
  drainInputBuffer() {
69
97
  while (this.inputBuffer.length > 0) {
@@ -13,6 +13,8 @@ const GOOGLE_ANTIGRAVITY_USER_AGENT = "antigravity/1.11.9 windows/amd64";
13
13
  const REQUEST_TIMEOUT_MS = 10_000;
14
14
  const DAY_SECONDS = 86_400;
15
15
  const HOUR_SECONDS = 3_600;
16
+ const MODEL_USAGE_WARNING_MIN_USED_PERCENT = 5;
17
+ const MODEL_USAGE_WARNING_MIN_ELAPSED_SECONDS = 6 * HOUR_SECONDS;
16
18
  const PI_AUTH_PATH = join(homedir(), ".pi", "agent", "auth.json");
17
19
  const DEFAULT_ANTIGRAVITY_PROJECT_ID = "rising-fact-p41fc";
18
20
  function getPiAuthPath() {
@@ -929,11 +931,11 @@ function modelUsageWindowWillExhaustBeforeReset(window, now) {
929
931
  return false;
930
932
  const timeUntilResetSeconds = Math.max(0, (window.resetAt - now) / 1000);
931
933
  const elapsedSeconds = Math.max(0, window.windowSeconds - timeUntilResetSeconds);
932
- if (elapsedSeconds <= 0)
934
+ if (elapsedSeconds < MODEL_USAGE_WARNING_MIN_ELAPSED_SECONDS)
933
935
  return false;
934
936
  const total = 100;
935
937
  const used = total - window.remainingPercent;
936
- if (used <= 0)
938
+ if (used < MODEL_USAGE_WARNING_MIN_USED_PERCENT)
937
939
  return false;
938
940
  const remaining = total - used;
939
941
  const averageRate = used / elapsedSeconds;
@@ -61,6 +61,8 @@ export class AppRequestHistory {
61
61
  if (this.cursor === undefined) {
62
62
  if (direction > 0)
63
63
  return false;
64
+ if (this.host.getInput().length > 0)
65
+ return false;
64
66
  this.draft = this.host.getInput();
65
67
  this.cursor = this.entries.length - 1;
66
68
  const entry = this.entries[this.cursor];
@@ -90,6 +90,7 @@ export declare class AppSessionEventController {
90
90
  snapshotState(): AppSessionEventControllerState;
91
91
  restoreState(state: AppSessionEventControllerState): void;
92
92
  reset(): void;
93
+ allEntries(): readonly Entry[];
93
94
  loadSessionHistory(): void;
94
95
  loadSessionHistoryAsync(options: {
95
96
  isCancelled: () => boolean;
@@ -99,6 +100,18 @@ export declare class AppSessionEventController {
99
100
  hasOlderSessionHistory(): boolean;
100
101
  isLoadingOlderSessionHistory(): boolean;
101
102
  loadOlderSessionHistory(options?: LoadOlderSessionHistoryOptions): Promise<boolean>;
103
+ /**
104
+ * Reveal the conversation entry for a session entry id (e.g. a user message
105
+ * selected from the jump menu) by shifting the sliding window directly onto
106
+ * it, then return its local entry id so the caller can scroll to it.
107
+ *
108
+ * Unlike paging older history in fixed increments, this works for any branch
109
+ * position in a single synchronous step and does not race with concurrent
110
+ * window re-anchors (e.g. while the agent is streaming). Returns undefined
111
+ * when the entry is neither in the full history window nor in the live
112
+ * entries (e.g. older than the loaded branch).
113
+ */
114
+ revealHistoryEntryForSessionEntryId(sessionEntryId: string): string | undefined;
102
115
  hasNewerSessionHistory(): boolean;
103
116
  isLoadingNewerSessionHistory(): boolean;
104
117
  loadNewerSessionHistory(options?: {
@@ -109,6 +109,9 @@ export class AppSessionEventController {
109
109
  this.assistantTextBuffer = "";
110
110
  this.olderHistoryLoader = undefined;
111
111
  }
112
+ allEntries() {
113
+ return this.historyEntries.length > 0 ? this.historyEntries : this.host.entries;
114
+ }
112
115
  loadSessionHistory() {
113
116
  const runtime = this.host.runtime();
114
117
  if (!runtime)
@@ -165,6 +168,30 @@ export class AppSessionEventController {
165
168
  return this.shiftHistoryWindow(-HISTORY_WINDOW_SHIFT_ENTRIES, options);
166
169
  return this.olderHistoryLoader?.loadOlder(options) ?? false;
167
170
  }
171
+ /**
172
+ * Reveal the conversation entry for a session entry id (e.g. a user message
173
+ * selected from the jump menu) by shifting the sliding window directly onto
174
+ * it, then return its local entry id so the caller can scroll to it.
175
+ *
176
+ * Unlike paging older history in fixed increments, this works for any branch
177
+ * position in a single synchronous step and does not race with concurrent
178
+ * window re-anchors (e.g. while the agent is streaming). Returns undefined
179
+ * when the entry is neither in the full history window nor in the live
180
+ * entries (e.g. older than the loaded branch).
181
+ */
182
+ revealHistoryEntryForSessionEntryId(sessionEntryId) {
183
+ if (this.historyEntries.length > 0) {
184
+ const index = this.historyEntries.findIndex((entry) => entry.kind === "user" && entry.sessionEntryId === sessionEntryId);
185
+ if (index === -1)
186
+ return undefined;
187
+ const targetStart = Math.max(0, Math.min(this.maxHistoryWindowStart(), index - Math.floor(this.historyWindowSize() / 2)));
188
+ if (targetStart !== this.historyWindowStart)
189
+ this.setHistoryWindowStart(targetStart);
190
+ return this.historyEntries[index]?.id;
191
+ }
192
+ const entry = this.host.entries.find((entry) => entry.kind === "user" && entry.sessionEntryId === sessionEntryId);
193
+ return entry?.id;
194
+ }
168
195
  hasNewerSessionHistory() {
169
196
  return this.historyEntries.length > 0 && this.historyWindowStart < this.maxHistoryWindowStart();
170
197
  }
@@ -155,6 +155,14 @@ export declare class AppTabsController {
155
155
  private scheduleTabPrewarm;
156
156
  private prewarmTabs;
157
157
  private cleanupOldProjectSessions;
158
+ /**
159
+ * Unlink a project session file and, best-effort, its DCP sidecar state.
160
+ * The sidecar path is derived from the session id in the first line of the
161
+ * `.jsonl` (mirrors the DCP module's `safeSessionFileName`), so retention
162
+ * never leaves orphan sidecars behind. Everything here is best-effort:
163
+ * session retention must never interrupt the terminal UI.
164
+ */
165
+ private unlinkSessionAndDcpSidecar;
158
166
  private preservedSessionPaths;
159
167
  private maxProjectSessions;
160
168
  }
@@ -1543,12 +1543,7 @@ export class AppTabsController {
1543
1543
  for (const session of sessions) {
1544
1544
  if (keep.has(session.path))
1545
1545
  continue;
1546
- try {
1547
- await unlink(session.path);
1548
- }
1549
- catch {
1550
- // Session retention must never interrupt the terminal UI.
1551
- }
1546
+ await this.unlinkSessionAndDcpSidecar(session.path);
1552
1547
  }
1553
1548
  }
1554
1549
  catch {
@@ -1558,6 +1553,42 @@ export class AppTabsController {
1558
1553
  this.retentionCleanupRunning = false;
1559
1554
  }
1560
1555
  }
1556
+ /**
1557
+ * Unlink a project session file and, best-effort, its DCP sidecar state.
1558
+ * The sidecar path is derived from the session id in the first line of the
1559
+ * `.jsonl` (mirrors the DCP module's `safeSessionFileName`), so retention
1560
+ * never leaves orphan sidecars behind. Everything here is best-effort:
1561
+ * session retention must never interrupt the terminal UI.
1562
+ */
1563
+ async unlinkSessionAndDcpSidecar(sessionPath) {
1564
+ let sidecarPath;
1565
+ try {
1566
+ const firstLine = (await readFile(sessionPath, "utf8")).split("\n", 1)[0]?.trim();
1567
+ if (firstLine) {
1568
+ const parsed = JSON.parse(firstLine);
1569
+ if (parsed.type === "session" && typeof parsed.id === "string" && parsed.id) {
1570
+ sidecarPath = join(dirname(sessionPath), "dcp-state", parsed.id.replace(/[^a-zA-Z0-9._-]/g, "_") + ".json");
1571
+ }
1572
+ }
1573
+ }
1574
+ catch {
1575
+ // Reading the session id is best-effort; proceed to unlink the file.
1576
+ }
1577
+ try {
1578
+ await unlink(sessionPath);
1579
+ }
1580
+ catch {
1581
+ // Session retention must never interrupt the terminal UI.
1582
+ }
1583
+ if (sidecarPath) {
1584
+ try {
1585
+ await unlink(sidecarPath);
1586
+ }
1587
+ catch {
1588
+ // Sidecar removal is best-effort; never interrupt the terminal UI.
1589
+ }
1590
+ }
1591
+ }
1561
1592
  preservedSessionPaths() {
1562
1593
  const preserved = new Set();
1563
1594
  const add = (sessionPath) => {
@@ -3,6 +3,7 @@ import type { Entry } from "../types.js";
3
3
  import { type WorkspaceMutation, type WorkspaceMutationFromToolInput, type WorkspaceMutationPreparation } from "./workspace-undo.js";
4
4
  export type AppWorkspaceActionsControllerHost = {
5
5
  readonly entries: Entry[];
6
+ allEntries?(): readonly Entry[];
6
7
  runtime(): AgentSessionRuntime | undefined;
7
8
  awaitCurrentSessionExtensions(runtime?: AgentSessionRuntime): Promise<void>;
8
9
  findUserEntry(entryId: string): Extract<Entry, {
@@ -48,8 +48,9 @@ export class AppWorkspaceActionsController {
48
48
  });
49
49
  if (branchUserEntries.length === 0)
50
50
  return;
51
+ const loadedEntries = this.host.allEntries?.() ?? this.host.entries;
51
52
  let branchIndex = 0;
52
- for (const entry of this.host.entries) {
53
+ for (const entry of loadedEntries) {
53
54
  if (entry.kind !== "user")
54
55
  continue;
55
56
  const sessionEntry = branchUserEntries[branchIndex];
@@ -15,7 +15,7 @@ const TERMINAL_BELL_ATTENTION_EVENT = "pix:terminal-bell:attention";
15
15
  * extensions, so the renderer emits this on the extension event bus.
16
16
  */
17
17
  const RETRY_ACTIVE_EVENT = "pix:retry-active";
18
- const DEFAULT_COMPLETION_NOTIFICATION_TITLE = "Pix - completion";
18
+ const DEFAULT_COMPLETION_NOTIFICATION_TITLE = "Pix - complete";
19
19
  const DEFAULT_ERROR_NOTIFICATION_TITLE = "Pix - error";
20
20
  const DEFAULT_QUESTION_NOTIFICATION_TITLE = "Pix - question";
21
21
  const DEFAULT_NOTIFICATION_MESSAGE = "{sessionName}";
@@ -1,4 +1,4 @@
1
- import { expandTabs, stringDisplayWidth } from "./terminal-width.js";
1
+ import { displayGraphemes, expandTabs, stringDisplayWidth } from "./terminal-width.js";
2
2
  import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
3
3
  const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
4
4
  export function formatMarkdownTables(text, maxWidth) {
@@ -211,20 +211,16 @@ function displayTokensWithRanges(text) {
211
211
  let current = "";
212
212
  let currentStart = 0;
213
213
  let currentWhitespace;
214
- for (let index = 0; index < text.length;) {
215
- const codePoint = text.codePointAt(index) ?? 0;
216
- const char = String.fromCodePoint(codePoint);
214
+ for (const { text: char, start } of displayGraphemes(text)) {
217
215
  const whitespace = /\s/u.test(char);
218
216
  if (current && currentWhitespace !== whitespace) {
219
- tokens.push({ text: current, start: currentStart, end: index, whitespace: currentWhitespace ?? false });
217
+ tokens.push({ text: current, start: currentStart, end: start, whitespace: currentWhitespace ?? false });
220
218
  current = "";
221
- currentStart = index;
222
219
  }
223
220
  if (!current)
224
- currentStart = index;
221
+ currentStart = start;
225
222
  current += char;
226
223
  currentWhitespace = whitespace;
227
- index += char.length;
228
224
  }
229
225
  if (current)
230
226
  tokens.push({ text: current, start: currentStart, end: text.length, whitespace: currentWhitespace ?? false });
@@ -235,19 +231,16 @@ function wrapDisplayTokenByWidth(token, width) {
235
231
  let chunkText = "";
236
232
  let chunkStart = token.start;
237
233
  let chunkWidth = 0;
238
- for (let index = token.start; index < token.end;) {
239
- const codePoint = token.text.codePointAt(index - token.start) ?? 0;
240
- const char = String.fromCodePoint(codePoint);
241
- const charWidth = stringDisplayWidth(char);
234
+ for (const { text: char, width: charWidth, start } of displayGraphemes(token.text)) {
235
+ const absoluteStart = token.start + start;
242
236
  if (chunkText && chunkWidth + charWidth > width) {
243
- chunks.push({ text: chunkText, start: chunkStart, end: index });
237
+ chunks.push({ text: chunkText, start: chunkStart, end: absoluteStart });
244
238
  chunkText = "";
245
- chunkStart = index;
239
+ chunkStart = absoluteStart;
246
240
  chunkWidth = 0;
247
241
  }
248
242
  chunkText += char;
249
243
  chunkWidth += charWidth;
250
- index += char.length;
251
244
  }
252
245
  chunks.push({ text: chunkText, start: chunkStart, end: token.end });
253
246
  return chunks;
@@ -506,20 +499,16 @@ function smartDisplayBreakIndex(text, width) {
506
499
  let used = 0;
507
500
  let fallbackIndex = 0;
508
501
  let breakIndex = 0;
509
- for (let index = 0; index < text.length;) {
510
- const codePoint = text.codePointAt(index) ?? 0;
511
- const char = String.fromCodePoint(codePoint);
512
- const nextIndex = index + char.length;
513
- const nextUsed = used + stringDisplayWidth(char);
502
+ for (const { text: char, width: charWidth, start, end } of displayGraphemes(text)) {
503
+ const nextUsed = used + charWidth;
514
504
  if (nextUsed > width)
515
505
  break;
516
506
  used = nextUsed;
517
- fallbackIndex = nextIndex;
518
- if (char === "/" && index > 0)
519
- breakIndex = index;
507
+ fallbackIndex = end;
508
+ if (char === "/" && start > 0)
509
+ breakIndex = start;
520
510
  else if (/[._:-]/u.test(char))
521
- breakIndex = nextIndex;
522
- index = nextIndex;
511
+ breakIndex = end;
523
512
  }
524
513
  return breakIndex > 0 ? breakIndex : fallbackIndex;
525
514
  }
@@ -3,6 +3,20 @@ export declare function stringDisplayWidth(text: string): number;
3
3
  export declare function sliceByDisplayWidth(text: string, width: number): string;
4
4
  export declare function displayIndexForColumn(text: string, column: number): number;
5
5
  export declare function sliceByDisplayColumns(text: string, startColumn: number, endColumn: number): string;
6
+ export type DisplayGrapheme = {
7
+ text: string;
8
+ width: number;
9
+ start: number;
10
+ end: number;
11
+ };
12
+ /**
13
+ * Grapheme clusters of `text` with their display width and absolute string
14
+ * indices. Iterating graphemes (instead of code points) is required for correct
15
+ * width accounting: multi-codepoint emoji such as `⚠️` (U+26A0 U+FE0F), keycaps,
16
+ * skin-tone modifiers and regional-indicator flags are one width-2 cluster even
17
+ * though several of their code points have zero width.
18
+ */
19
+ export declare function displayGraphemes(text: string): DisplayGrapheme[];
6
20
  export declare function padOrTrimDisplay(text: string, width: number): string;
7
21
  export declare function wrapDisplayLine(text: string, width: number): string[];
8
22
  export declare function wrapDisplayLineByWords(text: string, width: number): string[];
@@ -1,7 +1,7 @@
1
1
  const TAB_WIDTH = 4;
2
2
  const ANSI_RESET = "\x1b[0m";
3
3
  const EMOJI_PRESENTATION_REGEX = /\p{Emoji_Presentation}/u;
4
- const EMOJI_REGEX = /\p{Emoji}/u;
4
+ const REGIONAL_INDICATOR_REGEX = /[\u{1F1E6}-\u{1F1FF}]/u;
5
5
  const GRAPHEME_SEGMENTER = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : undefined;
6
6
  export function expandTabs(text, tabWidth = TAB_WIDTH) {
7
7
  if (!text.includes("\t"))
@@ -86,6 +86,20 @@ export function sliceByDisplayColumns(text, startColumn, endColumn) {
86
86
  const endIndex = Math.max(startIndex, displayIndexForColumn(text, endColumn));
87
87
  return text.slice(startIndex, endIndex);
88
88
  }
89
+ /**
90
+ * Grapheme clusters of `text` with their display width and absolute string
91
+ * indices. Iterating graphemes (instead of code points) is required for correct
92
+ * width accounting: multi-codepoint emoji such as `⚠️` (U+26A0 U+FE0F), keycaps,
93
+ * skin-tone modifiers and regional-indicator flags are one width-2 cluster even
94
+ * though several of their code points have zero width.
95
+ */
96
+ export function displayGraphemes(text) {
97
+ const graphemes = [];
98
+ for (const cluster of indexedDisplayClusters(text)) {
99
+ graphemes.push({ text: cluster.text, width: cluster.width, start: cluster.start, end: cluster.end });
100
+ }
101
+ return graphemes;
102
+ }
89
103
  export function padOrTrimDisplay(text, width) {
90
104
  const safeWidth = Math.max(0, width);
91
105
  if (isPrintableAscii(text)) {
@@ -280,7 +294,22 @@ function graphemeDisplayWidth(text) {
280
294
  return width;
281
295
  }
282
296
  function isEmojiGrapheme(text) {
283
- return EMOJI_PRESENTATION_REGEX.test(text) || (EMOJI_REGEX.test(text) && (text.includes("\ufe0f") || text.includes("\u20e3")));
297
+ // Default-presentation emoji ( 🚀 ❌), supplementary pictographs, and
298
+ // regional-indicator flags render two cells wide in conforming terminals
299
+ // including iTerm2 and Zed, so they are measured at width 2.
300
+ if (EMOJI_PRESENTATION_REGEX.test(text))
301
+ return true;
302
+ // Keycap sequences (base + U+FE0F + U+20E3, e.g. 1️⃣) and regional-indicator
303
+ // pairs (🇷🇺) also occupy two cells.
304
+ if (text.includes("\u20e3"))
305
+ return true;
306
+ if (REGIONAL_INDICATOR_REGEX.test(text) && /[\u{1F1E6}-\u{1F1FF}]{2}/u.test(text))
307
+ return true;
308
+ // Symbols promoted to an emoji glyph only by a variation selector (⚠️ ✔️ ©️
309
+ // ☀️) keep their base width of 1. Their base code point is East-Asian-Width
310
+ // Ambiguous, and iTerm2/Zed/wcwidth render them one cell wide; counting them
311
+ // as 2 would misalign table columns and shorten rendered rows.
312
+ return false;
284
313
  }
285
314
  function codePointLength(codePoint) {
286
315
  return codePoint > 0xffff ? 2 : 1;
package/dist/theme.js CHANGED
@@ -9,7 +9,7 @@ export const THEMES = {
9
9
  muted: "#7d8590",
10
10
  headerForeground: "#c9d1d9",
11
11
  headerBackground: "#161b22",
12
- statusForeground: "#8b949e",
12
+ statusForeground: "#9ba5af",
13
13
  statusBackground: "#0f1520",
14
14
  inputForeground: "#f0f6fc",
15
15
  inputBackground: "#090d13",
@@ -64,7 +64,7 @@ export const THEMES = {
64
64
  muted: "#64748b",
65
65
  headerForeground: "#0f172a",
66
66
  headerBackground: "#e2e8f0",
67
- statusForeground: "#475569",
67
+ statusForeground: "#566578",
68
68
  statusBackground: "#edf0f4",
69
69
  inputForeground: "#0f172a",
70
70
  inputBackground: "#f8fafc",