pi-one-ui 0.3.1 → 0.5.0

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.
@@ -22,7 +22,7 @@ import {
22
22
  formatUsernameHostLabel,
23
23
  } from "../../shared/format.ts";
24
24
  import { resolveRuntimeSymbol } from "../../shared/icons.ts";
25
- import { renderStyleForSource } from "../../shared/style.ts";
25
+ import { renderSourceColor, renderStyleForSource } from "../../shared/style.ts";
26
26
  import { sanitizeEditorMetadataText } from "../editor/editor-metadata-format.ts";
27
27
  import {
28
28
  collectExtensionStatusSegments,
@@ -322,10 +322,12 @@ export function installFooter(
322
322
  separatorText[config.components.footer.styles.starship.separator],
323
323
  );
324
324
  const innerWidth = Math.max(1, width - 2);
325
- const cwdLabel = renderStyleForSource(
325
+ const cwdLabel = renderSourceColor(
326
326
  theme,
327
327
  colorSource,
328
328
  config.colors.cwd,
329
+ "cwd",
330
+ "bold cyan",
329
331
  formattedCwd,
330
332
  );
331
333
  const needsSessionName =
@@ -932,10 +934,12 @@ export function installFooter(
932
934
 
933
935
  const chunkBudget = compactChunkBudget(innerWidth);
934
936
  const compactCwdLabel = truncateToWidth(
935
- renderStyleForSource(
937
+ renderSourceColor(
936
938
  theme,
937
939
  colorSource,
938
940
  config.colors.cwd,
941
+ "cwd",
942
+ "bold cyan",
939
943
  sanitizeEditorMetadataText(
940
944
  formatCwdLabel(ctx.cwd, config.icons.cwd, {
941
945
  mode: "basename",
@@ -10,6 +10,7 @@ import { isSafeSgrStylePrefix } from "../../shared/style.ts";
10
10
  import { renderWorkingLineHigh } from "./working-line.ts";
11
11
 
12
12
  export const TURN_SUMMARY_ENTRY_TYPE = "zentui-turn-summary";
13
+ export const MIN_OUTPUT_RATE_WINDOW_MS = 500;
13
14
  const TURN_SUMMARY_VERSION = 3;
14
15
  const SGR_RESET = "\x1b[0m";
15
16
 
@@ -18,6 +19,7 @@ export type LiveTokenDisplay = Readonly<{
18
19
  input: number;
19
20
  output: number;
20
21
  outputApproximate: boolean;
22
+ outputTokensPerSecond?: number;
21
23
  }>;
22
24
  export type ThoughtSnapshot = Readonly<{ durationMs: number; active: boolean }>;
23
25
 
@@ -70,6 +72,8 @@ type ResponseState = {
70
72
  outputAnchor: number;
71
73
  outputAnchorCodePoints: number;
72
74
  liveOutputFloor: number;
75
+ startedAt: number;
76
+ outputTokensPerSecond?: number;
73
77
  lastDisplay?: LiveTokenDisplay;
74
78
  estimateIncomplete: boolean;
75
79
  estimateEnabled: boolean;
@@ -515,7 +519,7 @@ export class InteractionMetricsTracker {
515
519
  this.commitSnapshot(run, run.response);
516
520
  this.closeResponse(run, run.response, now);
517
521
  }
518
- this.openResponse(run);
522
+ this.openResponse(run, now);
519
523
  }
520
524
 
521
525
  messageUpdate(
@@ -549,7 +553,7 @@ export class InteractionMetricsTracker {
549
553
  contentChanged = this.applyContentEvent(response, validated);
550
554
  }
551
555
  const usageChanged = tokens ? this.applyLiveUsage(response, tokens) : false;
552
- const display = this.liveDisplay(response);
556
+ const display = this.liveDisplay(response, now);
553
557
  const displayChanged = !this.sameDisplay(response.lastDisplay, display);
554
558
  response.lastDisplay = display;
555
559
  return {
@@ -570,20 +574,31 @@ export class InteractionMetricsTracker {
570
574
  if (response.responseIdKey && key && response.responseIdKey !== key)
571
575
  return { status: "rejected" };
572
576
  if (!response.responseIdKey && key) response.responseIdKey = key;
573
- const preCloseDisplay = this.liveDisplay(response);
574
577
  let source: "final" | "last-snapshot" | "no-snapshot";
575
578
  if (tokens) {
576
579
  response.snapshot = tokens;
577
580
  response.hasSnapshot = true;
581
+ this.updateOutputRate(response, tokens.output, now);
578
582
  source = "final";
579
583
  } else source = response.hasSnapshot ? "last-snapshot" : "no-snapshot";
584
+ const preCloseDisplay = this.liveDisplay(
585
+ response,
586
+ tokens ? undefined : now,
587
+ );
580
588
  this.commitSnapshot(run, response);
581
589
  const committed = this.totalTokens();
582
590
  const preserveEstimate =
583
591
  source !== "final" && response.lastDisplay?.outputApproximate === true;
592
+ const outputRate = preCloseDisplay.outputTokensPerSecond;
584
593
  const displayTokens = preserveEstimate
585
594
  ? { ...preCloseDisplay, outputApproximate: true }
586
- : { ...committed, outputApproximate: false };
595
+ : {
596
+ ...committed,
597
+ outputApproximate: false,
598
+ ...(outputRate === undefined
599
+ ? {}
600
+ : { outputTokensPerSecond: outputRate }),
601
+ };
587
602
  run.lastClosedDisplay = preserveEstimate ? displayTokens : undefined;
588
603
  this.closeResponse(run, response, now);
589
604
  return { status: "accepted", tokens: committed, displayTokens, source };
@@ -770,7 +785,11 @@ export class InteractionMetricsTracker {
770
785
  );
771
786
  }
772
787
 
773
- private openResponse(run: RunState, key?: string): ResponseState {
788
+ private openResponse(
789
+ run: RunState,
790
+ now: number,
791
+ key?: string,
792
+ ): ResponseState {
774
793
  run.responseOrdinal = safeAdd(run.responseOrdinal, 1);
775
794
  run.lastClosedDisplay = undefined;
776
795
  const estimateEnabled =
@@ -787,6 +806,7 @@ export class InteractionMetricsTracker {
787
806
  outputAnchor: 0,
788
807
  outputAnchorCodePoints: 0,
789
808
  liveOutputFloor: 0,
809
+ startedAt: safeTime(now),
790
810
  estimateIncomplete: !estimateEnabled,
791
811
  estimateEnabled,
792
812
  };
@@ -996,7 +1016,7 @@ export class InteractionMetricsTracker {
996
1016
  return true;
997
1017
  }
998
1018
 
999
- private liveDisplay(response: ResponseState): LiveTokenDisplay {
1019
+ private liveDisplay(response: ResponseState, now?: number): LiveTokenDisplay {
1000
1020
  const committed = this.committedTokens();
1001
1021
  const postAnchorCodePoints = Math.max(
1002
1022
  0,
@@ -1013,6 +1033,8 @@ export class InteractionMetricsTracker {
1013
1033
  responseOutput === response.outputAnchor &&
1014
1034
  !response.estimateIncomplete;
1015
1035
  response.liveOutputFloor = responseOutput;
1036
+ if (now !== undefined) this.updateOutputRate(response, responseOutput, now);
1037
+ const outputRate = response.outputTokensPerSecond;
1016
1038
  return {
1017
1039
  input: safeAdd(
1018
1040
  committed.input,
@@ -1020,9 +1042,26 @@ export class InteractionMetricsTracker {
1020
1042
  ),
1021
1043
  output: safeAdd(committed.output, responseOutput),
1022
1044
  outputApproximate: !exactCoverage,
1045
+ ...(outputRate === undefined
1046
+ ? {}
1047
+ : { outputTokensPerSecond: outputRate }),
1023
1048
  };
1024
1049
  }
1025
1050
 
1051
+ private updateOutputRate(
1052
+ response: ResponseState,
1053
+ responseOutput: number,
1054
+ now: number,
1055
+ ): void {
1056
+ const elapsedMs = durationMs(response.startedAt, now);
1057
+ const rate =
1058
+ elapsedMs >= MIN_OUTPUT_RATE_WINDOW_MS && responseOutput > 0
1059
+ ? Math.min(Number.MAX_SAFE_INTEGER, (responseOutput * 1000) / elapsedMs)
1060
+ : undefined;
1061
+ response.outputTokensPerSecond =
1062
+ rate !== undefined && rate >= 0.1 ? rate : undefined;
1063
+ }
1064
+
1026
1065
  private runTokens(run: RunState): InteractionTokens {
1027
1066
  return run.response?.hasSnapshot
1028
1067
  ? addTokens(run.committed, run.response.snapshot)
@@ -1104,7 +1143,8 @@ export class InteractionMetricsTracker {
1104
1143
  return (
1105
1144
  left?.input === right?.input &&
1106
1145
  left?.output === right?.output &&
1107
- left?.outputApproximate === right?.outputApproximate
1146
+ left?.outputApproximate === right?.outputApproximate &&
1147
+ left?.outputTokensPerSecond === right?.outputTokensPerSecond
1108
1148
  );
1109
1149
  }
1110
1150
  }
@@ -550,7 +550,12 @@ export type WorkingLineRuntimeSegments = {
550
550
  tool?: string;
551
551
  elapsedMs?: number;
552
552
  thought?: { durationMs: number; active: boolean };
553
- tokens?: { input: number; output: number; outputApproximate?: boolean };
553
+ tokens?: {
554
+ input: number;
555
+ output: number;
556
+ outputApproximate?: boolean;
557
+ outputTokensPerSecond?: number;
558
+ };
554
559
  };
555
560
 
556
561
  export type WorkingLineFrameState = {
@@ -617,11 +622,18 @@ export function formatWorkingLineTokens(
617
622
  tokens.input < 0 ||
618
623
  tokens.output < 0 ||
619
624
  (tokens.outputApproximate !== undefined &&
620
- typeof tokens.outputApproximate !== "boolean")
625
+ typeof tokens.outputApproximate !== "boolean") ||
626
+ (tokens.outputTokensPerSecond !== undefined &&
627
+ (!Number.isFinite(tokens.outputTokensPerSecond) ||
628
+ tokens.outputTokensPerSecond < 0))
621
629
  ) {
622
630
  return undefined;
623
631
  }
624
- return `↑${formatCount(tokens.input)} ↓${formatCount(tokens.output)}`;
632
+ const outputRate =
633
+ tokens.outputTokensPerSecond && tokens.outputTokensPerSecond > 0
634
+ ? ` ⚡${formatCount(Math.round(tokens.outputTokensPerSecond * 10) / 10)} tok/s`
635
+ : "";
636
+ return `↑${formatCount(tokens.input)} ↓${formatCount(tokens.output)}${outputRate}`;
625
637
  }
626
638
 
627
639
  function truncateWithEllipsis(value: string, capacity: number): string {
@@ -1174,6 +1186,15 @@ export class WorkingLineController {
1174
1186
  startTurn(ctx: WorkingLineContext): WorkingLineReconcileResult {
1175
1187
  const config = this.getConfig().components.workingLine;
1176
1188
  this.activeTools.clear();
1189
+ if (this.tokens?.outputTokensPerSecond !== undefined) {
1190
+ this.tokens = {
1191
+ input: this.tokens.input,
1192
+ output: this.tokens.output,
1193
+ ...(this.tokens.outputApproximate === undefined
1194
+ ? {}
1195
+ : { outputApproximate: this.tokens.outputApproximate }),
1196
+ };
1197
+ }
1177
1198
  const selectedMessage = selectWorkingLineMessage(config, this.random);
1178
1199
  if (!config.enabled) {
1179
1200
  this.selectedMessage = selectedMessage;
@@ -23,7 +23,6 @@ export type IconGlyphs = {
23
23
  deleted: string;
24
24
  typechanged: string;
25
25
  cacheHit: string;
26
- editorPrompt: string;
27
26
  rail: string;
28
27
  username: string;
29
28
  time: string;
@@ -48,7 +47,6 @@ export const ICON_GLYPH_KEYS = [
48
47
  "deleted",
49
48
  "typechanged",
50
49
  "cacheHit",
51
- "editorPrompt",
52
50
  "rail",
53
51
  "username",
54
52
  "time",
@@ -79,7 +77,6 @@ export const NERD_DEFAULT_ICONS: IconGlyphs = {
79
77
  deleted: "✘",
80
78
  typechanged: "T",
81
79
  cacheHit: "󰆼",
82
- editorPrompt: "",
83
80
  rail: "│",
84
81
  username: "",
85
82
  time: "",
@@ -103,7 +100,6 @@ export const ASCII_DEFAULT_ICONS: IconGlyphs = {
103
100
  deleted: "x",
104
101
  typechanged: "T",
105
102
  cacheHit: "c",
106
- editorPrompt: "",
107
103
  rail: "|",
108
104
  username: "@",
109
105
  time: "t",
@@ -427,6 +427,54 @@ export function renderStyleForSourceOrFallback(
427
427
  return renderStyleForSource(theme, source, style ?? fallbackStyle, text);
428
428
  }
429
429
 
430
+ /**
431
+ * 主题画板存在性探测:pi Theme 暴露 fgColors Map;测试或其他实现可
432
+ * 提供 hasThemeToken 回调,缺失时保守按“无该 token”处理。
433
+ */
434
+ function themeHasToken(theme: ThemeLike, token: string): boolean {
435
+ if (
436
+ typeof (theme as { hasThemeToken?: (t: string) => boolean })
437
+ .hasThemeToken === "function"
438
+ ) {
439
+ return (
440
+ theme as unknown as { hasThemeToken(t: string): boolean }
441
+ ).hasThemeToken(token);
442
+ }
443
+ const fgColors = (theme as unknown as { fgColors?: Map<string, string> })
444
+ .fgColors;
445
+ return fgColors instanceof Map && fgColors.has(token);
446
+ }
447
+
448
+ /**
449
+ * 颜色统一渲染入口(配置优先,其次主题 token,最后原生默认):
450
+ * - 配置了 spec:按 colorSource 现有语义解释(theme=token/hex,terminal=终端色);
451
+ * - 未配置且 theme 源:主题定义了 themeToken 时用主题色(可指向 vars 变量),
452
+ * 否则回落原生默认 themeDefault;
453
+ * - 未配置且 terminal 源:使用固定终端默认 terminalDefault。
454
+ */
455
+ export function renderSourceColor(
456
+ theme: ThemeLike,
457
+ source: ColorSource,
458
+ spec: ColorSpec | undefined,
459
+ themeToken: string,
460
+ themeDefault: ColorSpec,
461
+ text: string,
462
+ ): string {
463
+ if (typeof spec === "string" && spec.trim() !== "") {
464
+ return renderStyleForSource(theme, source, spec, text);
465
+ }
466
+ // 未配置时:theme 源优先主题专有 token,terminal 源与主题缺 token 时
467
+ // 都回落 Pi 原生默认(主题语义色),保证两种 colorSource 默认观感一致。
468
+ if (source !== "terminal" && themeHasToken(theme, themeToken)) {
469
+ try {
470
+ return theme.fg(themeToken, text);
471
+ } catch {
472
+ // 主题色解析失败时继续回退原生默认。
473
+ }
474
+ }
475
+ return renderThemeStyle(theme, themeDefault, text);
476
+ }
477
+
430
478
  export function renderStyleForSourceOrFallbackStrict(
431
479
  theme: ThemeLike,
432
480
  source: ColorSource,
@@ -52,6 +52,11 @@ export const TOOL_EXPANDED_BACKGROUND_PATCH = Symbol.for(
52
52
  "pi.ccstyle.tool-expanded-background-patch",
53
53
  );
54
54
 
55
+ // ── 工具 updateDisplay 跨 toggle 重建缓存 ──
56
+ export const TOGGLE_RENDER_CACHE_PATCH = Symbol.for(
57
+ "pi.ccstyle.toggle-render-cache-patch",
58
+ );
59
+
55
60
  // ── 消息组件补丁 ──
56
61
  export const MESSAGE_DISPLAY_PATCH = Symbol.for(
57
62
  "pi.ccstyle.message-display-patch",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-one-ui",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "A single, customizable Pi UI package with layout-oriented Context and Shell rendering for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -58,8 +58,7 @@
58
58
  "themes": [
59
59
  "./themes/cc-dark.json",
60
60
  "./themes/cc-light.json"
61
- ],
62
- "image": "https://raw.githubusercontent.com/minuque/pi-cc-extensions/main/assets/readme/preview.png"
61
+ ]
63
62
  },
64
63
  "devDependencies": {
65
64
  "@biomejs/biome": "^2.5.5",
@@ -81,7 +81,10 @@
81
81
  "thinkingHigh": "accentBright",
82
82
  "thinkingXhigh": "#4db9cd",
83
83
  "thinkingMax": "cyan",
84
- "bashMode": "green"
84
+ "bashMode": "green",
85
+ "cwd": "blue",
86
+ "editorModel": "blue",
87
+ "editorBorder": "blue"
85
88
  },
86
89
  "export": {
87
90
  "pageBg": "bg",
@@ -81,7 +81,10 @@
81
81
  "thinkingHigh": "accentBright",
82
82
  "thinkingXhigh": "#4a8fa3",
83
83
  "thinkingMax": "cyan",
84
- "bashMode": "green"
84
+ "bashMode": "green",
85
+ "cwd": "blue",
86
+ "editorModel": "blue",
87
+ "editorBorder": "blue"
85
88
  },
86
89
  "export": {
87
90
  "pageBg": "bg",
@@ -1,112 +0,0 @@
1
- import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
- import type { ZentuiConfig } from "../../app/config/shell.ts";
4
- import {
5
- renderStyleForSourceOrFallback,
6
- type SourceStyleFallback,
7
- } from "../../shared/style.ts";
8
- import {
9
- applyOwnedLayoutBackground,
10
- fillTerminalLine,
11
- renderCompletionRows,
12
- } from "./completion-menu.ts";
13
- import { sanitizeEditorMetadataText } from "./editor-metadata-format.ts";
14
-
15
- export const ACCENT_RAIL_CHROME_WIDTH = 2;
16
-
17
- const ACCENT_RAIL_FALLBACK: SourceStyleFallback = {
18
- theme: "syntaxNumber",
19
- terminal: "fg:215",
20
- };
21
-
22
- export type AccentRailViewport = {
23
- above?: string;
24
- below?: string;
25
- };
26
-
27
- export type AccentRailEditorFrameOptions = {
28
- width: number;
29
- editorLines: string[];
30
- autocompleteLines?: string[];
31
- viewport?: AccentRailViewport;
32
- uiTheme: Theme;
33
- config: ZentuiConfig;
34
- };
35
-
36
- function clampLines(lines: string[], width: number): string[] {
37
- const maxWidth = Math.max(0, width);
38
- return lines.map((line) => truncateToWidth(line, maxWidth, ""));
39
- }
40
-
41
- function selectedRail(config: ZentuiConfig): string {
42
- const style = config.components.editor.styles["accent-rail"];
43
- const fallback = config.icons.mode === "ascii" ? "|" : "▎";
44
- const configured =
45
- config.icons.mode === "ascii" ? style.asciiRail : style.rail;
46
- const sanitized = sanitizeEditorMetadataText(configured);
47
- const glyph = truncateToWidth(sanitized, 1, "");
48
- return visibleWidth(glyph) === 1 ? glyph : fallback;
49
- }
50
-
51
- function viewportLabel(
52
- direction: "above" | "below",
53
- count: string | undefined,
54
- ): string | undefined {
55
- if (!count || !/^[1-9]\d*$/.test(count)) return undefined;
56
- return `${direction === "above" ? "↑" : "↓"} ${count} more`;
57
- }
58
-
59
- /** Pure compact accent-rail composition shared by live rendering and settings previews. */
60
- export function renderAccentRailEditorFrame({
61
- width,
62
- editorLines,
63
- autocompleteLines = [],
64
- viewport = {},
65
- uiTheme,
66
- config,
67
- }: AccentRailEditorFrameOptions): string[] {
68
- if (width < ACCENT_RAIL_CHROME_WIDTH + 1) {
69
- return clampLines([...editorLines, ...autocompleteLines], width);
70
- }
71
-
72
- const contentWidth = width - ACCENT_RAIL_CHROME_WIDTH;
73
- const rail = renderStyleForSourceOrFallback(
74
- uiTheme,
75
- config.components.editor.colorSource,
76
- config.colors.editorRail,
77
- ACCENT_RAIL_FALLBACK,
78
- selectedRail(config),
79
- );
80
- const above = config.components.editor.viewportIndicators
81
- ? viewportLabel("above", viewport.above)
82
- : undefined;
83
- const below = config.components.editor.viewportIndicators
84
- ? viewportLabel("below", viewport.below)
85
- : undefined;
86
- const rows = [
87
- ...(above ? [above] : []),
88
- ...editorLines,
89
- ...(below ? [below] : []),
90
- ];
91
- const transparent =
92
- config.components.editor.styles["accent-rail"].transparent;
93
- const layout = rows.map((line) => {
94
- const railCell = transparent
95
- ? rail
96
- : applyOwnedLayoutBackground(uiTheme, rail);
97
- const bodyText = ` ${fillTerminalLine(line, contentWidth)}`;
98
- const body = transparent
99
- ? bodyText
100
- : applyOwnedLayoutBackground(uiTheme, bodyText);
101
- return `${railCell}${body}`;
102
- });
103
- const autocompleteLayout = renderCompletionRows({
104
- lines: autocompleteLines,
105
- width,
106
- theme: uiTheme,
107
- selectedPrefix: `${rail} `,
108
- ownedBackground: !transparent,
109
- });
110
-
111
- return clampLines([...layout, ...autocompleteLayout], width);
112
- }