pi-one-ui 0.7.0 → 0.7.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.en.md +19 -6
  3. package/README.md +19 -6
  4. package/extensions/app/config/context.ts +42 -0
  5. package/extensions/app/config/editor.ts +80 -0
  6. package/extensions/app/config/footer.ts +291 -0
  7. package/extensions/app/config/panel.ts +1 -5
  8. package/extensions/app/config/renderer.ts +1 -6
  9. package/extensions/app/config/shell.ts +358 -1435
  10. package/extensions/app/config/store.ts +22 -1
  11. package/extensions/app/config/values.ts +33 -0
  12. package/extensions/app/config/working-line.ts +100 -0
  13. package/extensions/app/overlay/input-router.ts +56 -32
  14. package/extensions/app/overlay/overlay-manager.ts +183 -21
  15. package/extensions/app/runtime/event-coordinator.ts +90 -42
  16. package/extensions/{features → app/runtime}/flush-docked-bash.ts +12 -2
  17. package/extensions/app/runtime/render-scheduler.ts +35 -31
  18. package/extensions/app/runtime/tui-runtime.ts +315 -312
  19. package/extensions/app/settings/controller.ts +202 -0
  20. package/extensions/app/settings/panel.ts +81 -0
  21. package/extensions/app/settings/presets.ts +43 -0
  22. package/extensions/features/context-inspector/index.ts +83 -662
  23. package/extensions/layouts/context/index.ts +58 -161
  24. package/extensions/layouts/context/message/user-message.ts +1 -1
  25. package/extensions/layouts/context/renderer/index.ts +31 -13
  26. package/extensions/layouts/context/renderer/mouse/interaction.ts +29 -14
  27. package/extensions/layouts/editor/controller.ts +9 -18
  28. package/extensions/layouts/editor/factory.ts +3 -3
  29. package/extensions/layouts/footer/controller.ts +30 -19
  30. package/extensions/layouts/footer/footer.ts +2 -2
  31. package/extensions/layouts/overlay/context-inspector.ts +497 -0
  32. package/extensions/{app → layouts}/overlay/selector-border.ts +5 -5
  33. package/extensions/{app → layouts}/overlay/selector-controller.ts +1 -1
  34. package/extensions/layouts/overlay/settings-panel.ts +359 -0
  35. package/extensions/{app/commands → layouts/overlay}/settings-previews.ts +4 -4
  36. package/extensions/layouts/working-line/controller.ts +3 -29
  37. package/extensions/layouts/working-line/working-line.ts +17 -114
  38. package/extensions/shared/working-line-text.ts +108 -0
  39. package/package.json +1 -1
  40. package/extensions/app/host/pi-extension-port.ts +0 -25
  41. package/extensions/app/host/pi-ui-port.ts +0 -52
  42. package/extensions/app/host/tui-capabilities.ts +0 -27
  43. package/extensions/app/ownership/layout-registry.ts +0 -65
  44. package/extensions/app/ownership.ts +0 -32
  45. package/extensions/app/panel.ts +0 -770
  46. package/extensions/app/presets.ts +0 -35
  47. package/extensions/app/runtime/runtime-state.ts +0 -83
  48. /package/extensions/{layouts/working-line → app/config}/working-line-messages.ts +0 -0
  49. /package/extensions/app/{ownership → runtime}/prototype-patch-registry.ts +0 -0
@@ -0,0 +1,359 @@
1
+ import {
2
+ getSettingsListTheme,
3
+ type Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type Component,
7
+ matchesKey,
8
+ type SettingItem,
9
+ SettingsList,
10
+ type TUI,
11
+ truncateToWidth,
12
+ } from "@earendil-works/pi-tui";
13
+ import {
14
+ DIFF_INDICATOR_MODES,
15
+ DIFF_VIEW_MODES,
16
+ } from "../../app/config/renderer.ts";
17
+ import type { SettingsSnapshot } from "../../app/settings/controller.ts";
18
+ import { PRESET_VALUES } from "../../app/settings/presets.ts";
19
+ import {
20
+ renderEditorSettingsPreview,
21
+ renderUserMessageSettingsPreview,
22
+ } from "./settings-previews.ts";
23
+
24
+ const SECTIONS = [
25
+ { id: "header", label: "Header" },
26
+ { id: "context", label: "Context" },
27
+ { id: "workingLine", label: "WorkingLine" },
28
+ { id: "editor", label: "Editor" },
29
+ { id: "footer", label: "Footer" },
30
+ { id: "features", label: "Features" },
31
+ { id: "presets", label: "Presets" },
32
+ ] as const;
33
+ type SectionId = (typeof SECTIONS)[number]["id"];
34
+ const onOff = (value: boolean) => (value ? "on" : "off");
35
+ const item = (
36
+ id: string,
37
+ label: string,
38
+ description: string,
39
+ currentValue: string,
40
+ values: readonly string[],
41
+ ): SettingItem => ({
42
+ id,
43
+ label,
44
+ description,
45
+ currentValue,
46
+ values: [...values],
47
+ });
48
+ const toggle = (
49
+ id: string,
50
+ label: string,
51
+ description: string,
52
+ enabled: boolean,
53
+ ) => item(id, label, description, onOff(enabled), ["on", "off"]);
54
+
55
+ function itemsFor(
56
+ section: SectionId,
57
+ snapshot: SettingsSnapshot,
58
+ ): SettingItem[] {
59
+ const { components } = snapshot.shell;
60
+ const config = snapshot.renderer;
61
+ switch (section) {
62
+ case "header":
63
+ return [
64
+ toggle(
65
+ "showStartupHeader",
66
+ "Startup header",
67
+ "Show the custom startup header on new sessions.",
68
+ config.showStartupHeader,
69
+ ),
70
+ ];
71
+ case "editor":
72
+ return [
73
+ item(
74
+ "editorStyle",
75
+ "Editor",
76
+ "on applies the Minimalist decoration; off restores Pi's native editor.",
77
+ components.editor.style,
78
+ ["on", "off"],
79
+ ),
80
+ item(
81
+ "editorBorderColorMode",
82
+ "Border color",
83
+ "static keeps one border color; adaptive shifts the border with the effort level.",
84
+ components.editor.borderColorMode,
85
+ ["static", "adaptive"],
86
+ ),
87
+ ];
88
+ case "workingLine":
89
+ return [
90
+ toggle(
91
+ "workingLineEnabled",
92
+ "Working line",
93
+ "Enable the sole owner of Pi's working row.",
94
+ components.workingLine.enabled,
95
+ ),
96
+ item(
97
+ "workingLineSpinner",
98
+ "Working spinner",
99
+ "Select the working line animation preset.",
100
+ components.workingLine.spinner,
101
+ ["braille", "star-bloom", "pinwheel", "claude-inspired", "pulse"],
102
+ ),
103
+ ];
104
+ case "footer":
105
+ return [
106
+ item(
107
+ "footerStyle",
108
+ "Footer",
109
+ "Choose Starship, native, or hidden footer rendering.",
110
+ components.footer.style,
111
+ ["starship", "native", "hidden"],
112
+ ),
113
+ ];
114
+ case "context":
115
+ return [
116
+ toggle(
117
+ "userMessagesEnabled",
118
+ "User messages",
119
+ "Enable custom rendering for previous user messages.",
120
+ components.userMessages.enabled,
121
+ ),
122
+ item(
123
+ "userMessagesStyle",
124
+ "Message style",
125
+ "Choose the previous user message treatment.",
126
+ components.userMessages.style,
127
+ ["framed", "framed-copy-friendly", "compact", "labeled"],
128
+ ),
129
+ item(
130
+ "contextMode",
131
+ "Tool style",
132
+ "on = rich cards, compact = one summary per assistant message, off = native.",
133
+ config.mode,
134
+ ["on", "compact", "off"],
135
+ ),
136
+ item(
137
+ "diffViewMode",
138
+ "Diff layout",
139
+ "Choose automatic, side-by-side, or unified diff layout.",
140
+ config.diffViewMode,
141
+ DIFF_VIEW_MODES,
142
+ ),
143
+ item(
144
+ "diffIndicatorMode",
145
+ "Diff indicator",
146
+ "Choose bars, classic +/- gutters, or no indicator.",
147
+ config.diffIndicatorMode,
148
+ DIFF_INDICATOR_MODES,
149
+ ),
150
+ toggle(
151
+ "diffWordWrap",
152
+ "Diff word wrap",
153
+ "Wrap long diff lines to the available terminal width.",
154
+ config.diffWordWrap,
155
+ ),
156
+ item(
157
+ "thinkingPreviewLines",
158
+ "Thinking preview",
159
+ "Number of thinking body lines shown before expansion.",
160
+ String(config.previewLines),
161
+ ["0", "1", "3", "5", "10"],
162
+ ),
163
+ toggle(
164
+ "dimThinkingText",
165
+ "Dim thinking text",
166
+ "Use the dim theme color for thinking body text.",
167
+ config.dimThinkingText,
168
+ ),
169
+ ];
170
+ case "features":
171
+ return [
172
+ toggle(
173
+ "enableSessionReference",
174
+ "Session reference",
175
+ "Enable @ session search and context injection.",
176
+ config.enableSessionReference,
177
+ ),
178
+ toggle(
179
+ "enableSubagentAutocomplete",
180
+ "Subagent autocomplete",
181
+ "Enable @ subagent completion and delegation hints.",
182
+ config.enableSubagentAutocomplete,
183
+ ),
184
+ toggle(
185
+ "enableContextCommand",
186
+ "Context command",
187
+ "Keep the /context inspection command enabled.",
188
+ config.enableContextCommand,
189
+ ),
190
+ toggle(
191
+ "enableAgentSummary",
192
+ "Agent summary",
193
+ "Show per-turn tool statistics after an agent turn.",
194
+ config.enableAgentSummary,
195
+ ),
196
+ toggle(
197
+ "enableAliases",
198
+ "Command aliases",
199
+ "Register /clear and /exit aliases.",
200
+ config.enableAliases,
201
+ ),
202
+ toggle(
203
+ "enableEffortCommand",
204
+ "Effort command",
205
+ "Register the /effort thinking-level command.",
206
+ config.enableEffortCommand,
207
+ ),
208
+ ];
209
+ case "presets":
210
+ return PRESET_VALUES.map((preset) =>
211
+ item(
212
+ `preset:${preset}`,
213
+ preset,
214
+ preset === "native"
215
+ ? "Disable custom Editor, Footer, WorkingLine and Context rendering."
216
+ : preset === "compact"
217
+ ? "Enable Editor, Footer, WorkingLine and compact Context rendering."
218
+ : "Enable Editor, Footer, WorkingLine and rich Context rendering.",
219
+ "apply",
220
+ ["apply"],
221
+ ),
222
+ );
223
+ }
224
+ }
225
+
226
+ const descriptions: Record<SectionId, string> = {
227
+ header: "Header owns startup branding and startup guidance.",
228
+ context: "Context owns messages, tool cards, diffs, thinking, and summaries.",
229
+ workingLine: "WorkingLine is the sole owner of Pi's unkeyed working row.",
230
+ editor: "Editor owns the input editor factory and its styles.",
231
+ footer: "Footer owns footer rendering and project status segments.",
232
+ features: "Feature switches are saved immediately and applied after /reload.",
233
+ presets:
234
+ "Presets change component settings while preserving detailed options.",
235
+ };
236
+
237
+ function renderPanelFrame(
238
+ theme: Theme,
239
+ width: number,
240
+ rows: string[],
241
+ ): string[] {
242
+ const safeWidth = Math.max(1, width);
243
+ const border = (text: string) => theme.fg("border", text);
244
+ if (safeWidth === 1) {
245
+ return [border("│"), ...rows.map(() => border("│")), border("│")];
246
+ }
247
+ const inner = safeWidth - 2;
248
+ const content = rows.map(
249
+ (row) =>
250
+ `${border("│")}${truncateToWidth(row, inner, "", true)}${border("│")}`,
251
+ );
252
+ return [
253
+ border(`╭${"─".repeat(inner)}╮`),
254
+ ...content,
255
+ border(`╰${"─".repeat(inner)}╯`),
256
+ ];
257
+ }
258
+
259
+ export type SettingsPanelOptions = {
260
+ readonly snapshot: () => SettingsSnapshot;
261
+ readonly change: (id: string, value: string) => void;
262
+ readonly onError: (error: unknown) => void;
263
+ };
264
+
265
+ /** 面板负责显示和交互,配置提交与焦点协作由 app 提供。 */
266
+ export function createSettingsPanel(
267
+ tui: Pick<TUI, "requestRender">,
268
+ theme: Theme,
269
+ done: () => void,
270
+ options: SettingsPanelOptions,
271
+ ): Component {
272
+ let activeIndex = 0;
273
+ let list: SettingsList;
274
+ const createList = (): void => {
275
+ list = new SettingsList(
276
+ itemsFor(SECTIONS[activeIndex].id, options.snapshot()),
277
+ 10,
278
+ getSettingsListTheme(),
279
+ (id, value) => {
280
+ try {
281
+ options.change(id, value);
282
+ list.updateValue(id, value);
283
+ } catch (error) {
284
+ options.onError(error);
285
+ createList();
286
+ list.selectItem(id);
287
+ } finally {
288
+ tui.requestRender();
289
+ }
290
+ },
291
+ done,
292
+ );
293
+ };
294
+ createList();
295
+ const switchSection = (delta: number): void => {
296
+ activeIndex = (activeIndex + delta + SECTIONS.length) % SECTIONS.length;
297
+ createList();
298
+ tui.requestRender();
299
+ };
300
+ return {
301
+ render(width) {
302
+ const inner = Math.max(0, width - 2);
303
+ const section = SECTIONS[activeIndex].id;
304
+ const tabs = SECTIONS.map((entry, index) =>
305
+ index === activeIndex
306
+ ? theme.fg("text", theme.bold(entry.label))
307
+ : theme.fg("dim", entry.label),
308
+ ).join(theme.fg("dim", " / "));
309
+ const config = options.snapshot().shell;
310
+ const previewWidth = Math.max(20, Math.min(72, inner - 2));
311
+ const preview =
312
+ section === "editor"
313
+ ? [
314
+ "",
315
+ theme.fg("muted", " Editor preview"),
316
+ ...renderEditorSettingsPreview(config, theme, previewWidth).map(
317
+ (line) => ` ${line}`,
318
+ ),
319
+ ]
320
+ : section === "context"
321
+ ? [
322
+ "",
323
+ theme.fg("muted", " User message preview"),
324
+ ...renderUserMessageSettingsPreview(
325
+ config,
326
+ theme,
327
+ previewWidth,
328
+ ).map((line) => ` ${line}`),
329
+ ]
330
+ : [];
331
+ return renderPanelFrame(theme, width, [
332
+ truncateToWidth(tabs, inner),
333
+ theme.fg("dim", "─".repeat(inner)),
334
+ theme.fg("muted", ` ${descriptions[section]}`),
335
+ "",
336
+ ...list.render(inner),
337
+ ...preview,
338
+ "",
339
+ truncateToWidth(
340
+ theme.fg(
341
+ "dim",
342
+ " Tab/Shift+Tab switch sections · Enter/Space change · Esc close",
343
+ ),
344
+ inner,
345
+ ),
346
+ ]);
347
+ },
348
+ invalidate: () => list.invalidate(),
349
+ handleInput(data) {
350
+ if (data === "\x1b[Z" || matchesKey(data, "shift+tab")) {
351
+ switchSection(-1);
352
+ } else if (matchesKey(data, "tab")) {
353
+ switchSection(1);
354
+ } else {
355
+ list.handleInput(data);
356
+ }
357
+ },
358
+ };
359
+ }
@@ -1,10 +1,10 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import { renderUserMessageStyle } from "../../layouts/context/message/user-message-styles.ts";
4
- import { sanitizeEditorMetadataText } from "../../layouts/editor/editor-metadata-format.ts";
5
- import { renderMinimalistFrame } from "../../layouts/editor/minimalist-editor.ts";
3
+ import type { PolishedTuiConfig } from "../../app/config/shell.ts";
6
4
  import { safeThemeFg } from "../../shared/style.ts";
7
- import type { PolishedTuiConfig } from "../config/shell.ts";
5
+ import { renderUserMessageStyle } from "../context/message/user-message-styles.ts";
6
+ import { sanitizeEditorMetadataText } from "../editor/editor-metadata-format.ts";
7
+ import { renderMinimalistFrame } from "../editor/minimalist-editor.ts";
8
8
 
9
9
  export const SETTINGS_PREVIEW_MAX_WIDTH = 72;
10
10
  export const SETTINGS_PREVIEW_MAX_ROWS = 10;
@@ -3,10 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
- import type {
7
- PolishedTuiConfig,
8
- WorkingLineComponentPatch,
9
- } from "../../app/config/shell.ts";
6
+ import type { PolishedTuiConfig } from "../../app/config/shell.ts";
10
7
  import type { SessionLifecycle } from "../../app/runtime/session-lifecycle.ts";
11
8
  import {
12
9
  InteractionMetricsTracker,
@@ -29,9 +26,6 @@ export type WorkingLineMessageEnd = Parameters<
29
26
  export type WorkingLineLayoutContext = {
30
27
  readonly pi: ExtensionAPI;
31
28
  readonly getConfig: () => PolishedTuiConfig;
32
- readonly saveComponent: (
33
- patch: WorkingLineComponentPatch,
34
- ) => PolishedTuiConfig;
35
29
  readonly getTheme: () => Theme;
36
30
  readonly sessionLifecycle: SessionLifecycle;
37
31
  readonly refresh: () => void;
@@ -223,28 +217,8 @@ export class WorkingLineLayoutController {
223
217
  }
224
218
  }
225
219
 
226
- /**
227
- * Applies a WorkingLine configuration patch and reconciles the host row.
228
- *
229
- * @param patch WorkingLine configuration changes to persist.
230
- * @param ctx Active Pi extension context.
231
- * @returns Whether the host working row accepted the change.
232
- */
233
- setComponent(
234
- patch: WorkingLineComponentPatch,
235
- ctx: ExtensionContext,
236
- ): { applied: boolean; reason?: string } {
237
- this.context.saveComponent(patch);
238
- return this.workingLine.reconcile(ctx);
239
- }
240
-
241
- /**
242
- * Reconciles WorkingLine configuration without changing persisted settings.
243
- *
244
- * @param ctx Active Pi extension context.
245
- * @returns Whether the host working row accepted the change.
246
- */
247
- reconcile(ctx: ExtensionContext) {
220
+ /** 应用 app 已提交的 WorkingLine 配置,并返回组件应用结果。 */
221
+ applyConfig(ctx: ExtensionContext): { applied: boolean; reason?: string } {
248
222
  return this.workingLine.reconcile(ctx);
249
223
  }
250
224
 
@@ -1,4 +1,3 @@
1
- import { stripVTControlCharacters } from "node:util";
2
1
  import { visibleWidth } from "@earendil-works/pi-tui";
3
2
  import type {
4
3
  ColorSpec,
@@ -7,6 +6,7 @@ import type {
7
6
  WorkingLineTextAnimation,
8
7
  ZentuiConfig,
9
8
  } from "../../app/config/shell.ts";
9
+ import { PI_WORKING_LINE_MESSAGES } from "../../app/config/working-line-messages.ts";
10
10
  import { formatCount } from "../../shared/format.ts";
11
11
  import {
12
12
  isSafeSgrStylePrefix,
@@ -14,30 +14,39 @@ import {
14
14
  renderThemeStyleOrFallback,
15
15
  type ThemeLike,
16
16
  } from "../../shared/style.ts";
17
- import { PI_WORKING_LINE_MESSAGES } from "./working-line-messages.ts";
17
+ import {
18
+ normalizeWorkingLineMessage,
19
+ normalizeWorkingLineMessages,
20
+ segmentGraphemes,
21
+ truncateGraphemes,
22
+ } from "../../shared/working-line-text.ts";
18
23
  import { WORKING_LINE_SPINNERS } from "./working-line-spinners.ts";
19
24
 
25
+ export {
26
+ MAX_WORKING_LINE_ENTRIES_EXAMINED,
27
+ MAX_WORKING_LINE_MESSAGE_CELLS,
28
+ MAX_WORKING_LINE_MESSAGES,
29
+ MAX_WORKING_LINE_NORMALIZED_CODE_UNITS,
30
+ MAX_WORKING_LINE_RAW_CODE_UNITS,
31
+ normalizeWorkingLineMessage,
32
+ normalizeWorkingLineMessages,
33
+ } from "../../shared/working-line-text.ts";
34
+
20
35
  export { WORKING_LINE_SPINNERS } from "./working-line-spinners.ts";
21
36
 
22
37
  export const BUILT_IN_WORKING_LINE_MESSAGES = PI_WORKING_LINE_MESSAGES;
23
38
  export const WORKING_LINE_FALLBACK_MESSAGE = "Working…";
24
39
 
25
- export const MAX_WORKING_LINE_MESSAGES = 48;
26
40
  /** Complete Loader row, including its two margins and Pi's indicator separator. */
27
41
  export const MAX_WORKING_LINE_ROW_CELLS = 80;
28
42
  /** Indicator payload budget inside the complete Loader row. */
29
43
  export const MAX_WORKING_LINE_FRAME_CELLS = MAX_WORKING_LINE_ROW_CELLS - 3;
30
- export const MAX_WORKING_LINE_MESSAGE_CELLS = 43;
31
44
  export const MAX_WORKING_LINE_TOOL_CELLS = 18;
32
45
  export const MAX_WORKING_LINE_FRAMES = 1024;
33
46
  export const MAX_WORKING_LINE_FRAME_CODE_UNITS = 512 * 1024;
34
- export const MAX_WORKING_LINE_RAW_CODE_UNITS = 4096;
35
- /** Keeps combining-rich visible text bounded before it is copied into every animation frame. */
36
- export const MAX_WORKING_LINE_NORMALIZED_CODE_UNITS = 256;
37
47
  /** Three modifiers plus one color are sufficient for each Working-line tier. */
38
48
  export const MAX_WORKING_LINE_STYLE_TOKENS = 4;
39
49
  export const MAX_WORKING_LINE_STYLE_CODE_UNITS = 48;
40
- export const MAX_WORKING_LINE_ENTRIES_EXAMINED = 256;
41
50
 
42
51
  const WORKING_LINE_FALLBACKS: Record<"low" | "mid" | "high", ColorSpec> = {
43
52
  low: "dim",
@@ -141,112 +150,6 @@ export class AgentDurationClock {
141
150
  }
142
151
  }
143
152
 
144
- function segmentGraphemes(value: string): Iterable<string> {
145
- try {
146
- const Segmenter = Intl.Segmenter;
147
- if (typeof Segmenter === "function") {
148
- const segments = new Segmenter(undefined, {
149
- granularity: "grapheme",
150
- }).segment(value);
151
- return {
152
- *[Symbol.iterator]() {
153
- for (const part of segments) yield part.segment;
154
- },
155
- };
156
- }
157
- } catch {
158
- // Without Intl.Segmenter, treat the complete value as one conservative grapheme.
159
- }
160
- return [value];
161
- }
162
-
163
- function truncateGraphemes(
164
- value: string,
165
- maximumCells: number,
166
- maximumCodeUnits = Number.POSITIVE_INFINITY,
167
- ): string {
168
- let width = 0;
169
- let codeUnits = 0;
170
- const output: string[] = [];
171
- for (const grapheme of segmentGraphemes(value)) {
172
- if (width >= maximumCells) break;
173
- const nextWidth = visibleWidth(grapheme);
174
- if (width + nextWidth > maximumCells) break;
175
- if (codeUnits + grapheme.length > maximumCodeUnits) break;
176
- output.push(grapheme);
177
- width += nextWidth;
178
- codeUnits += grapheme.length;
179
- }
180
- while (output.length > 0 && /^\s+$/u.test(output.at(-1) ?? "")) output.pop();
181
- return output.join("");
182
- }
183
-
184
- function boundRawWorkingLineInput(value: string): string {
185
- if (value.length <= MAX_WORKING_LINE_RAW_CODE_UNITS) return value;
186
- const prefix = value.slice(0, MAX_WORKING_LINE_RAW_CODE_UNITS);
187
- const graphemes = [...segmentGraphemes(prefix)];
188
- // The last segmented item may be only the prefix of a grapheme that crosses the raw bound.
189
- graphemes.pop();
190
- return graphemes.join("");
191
- }
192
-
193
- function trimGraphemeWhitespace(value: string): string {
194
- const graphemes = [...segmentGraphemes(value)];
195
- while (graphemes.length > 0 && /^\s+$/u.test(graphemes[0] ?? ""))
196
- graphemes.shift();
197
- while (graphemes.length > 0 && /^\s+$/u.test(graphemes.at(-1) ?? ""))
198
- graphemes.pop();
199
- return graphemes.join("");
200
- }
201
-
202
- function stripC1TerminalSequences(value: string): string {
203
- return value
204
- .replaceAll(
205
- /[\u0090\u0098\u009d\u009e\u009f][\s\S]*?(?:\u0007|\u009c|\x1b\\|$)/g,
206
- "",
207
- )
208
- .replaceAll(/\u009b[0-?]*[ -/]*[@-~]/g, "");
209
- }
210
-
211
- /** Normalize untrusted user-authored text before it can reach Pi's working row. */
212
- export function normalizeWorkingLineMessage(value: unknown): string {
213
- if (typeof value !== "string") return "";
214
- const bounded = boundRawWorkingLineInput(value);
215
- const withoutTerminalSequences = stripVTControlCharacters(
216
- stripC1TerminalSequences(bounded),
217
- );
218
- const withoutControls = withoutTerminalSequences
219
- .replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, " ")
220
- .replaceAll(
221
- /[\u034f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u206f]/g,
222
- "",
223
- );
224
- const normalized = trimGraphemeWhitespace(
225
- withoutControls.normalize("NFC").replaceAll(/\s+/gu, " "),
226
- );
227
- const truncated = truncateGraphemes(
228
- normalized,
229
- MAX_WORKING_LINE_MESSAGE_CELLS,
230
- MAX_WORKING_LINE_NORMALIZED_CODE_UNITS,
231
- );
232
- return visibleWidth(truncated) > 0 ? truncated : "";
233
- }
234
-
235
- export function normalizeWorkingLineMessages(values: unknown): string[] {
236
- if (!Array.isArray(values)) return [];
237
- const output: string[] = [];
238
- const seen = new Set<string>();
239
- const examined = Math.min(values.length, MAX_WORKING_LINE_ENTRIES_EXAMINED);
240
- for (let index = 0; index < examined; index += 1) {
241
- const normalized = normalizeWorkingLineMessage(values[index]);
242
- if (!normalized || seen.has(normalized)) continue;
243
- seen.add(normalized);
244
- output.push(normalized);
245
- if (output.length === MAX_WORKING_LINE_MESSAGES) break;
246
- }
247
- return output;
248
- }
249
-
250
153
  export function effectiveWorkingLineMessages(
251
154
  config: WorkingLineComponentConfig,
252
155
  ): string[] {