@selesai/code 0.9.14 → 0.10.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.
Files changed (263) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +6 -9
  3. package/dist/defaults/models.json +79 -29
  4. package/dist/defaults/settings.json +3 -7
  5. package/dist/extensions/package.json +3 -4
  6. package/dist/extensions/pi-zentui/.editorconfig +12 -0
  7. package/dist/extensions/pi-zentui/.github/CODEOWNERS +1 -0
  8. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/bug_report.yml +110 -0
  9. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config.yml +8 -0
  10. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/config_question.yml +56 -0
  11. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/feature_request.yml +63 -0
  12. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/rendering_issue.yml +106 -0
  13. package/dist/extensions/pi-zentui/.github/ISSUE_TEMPLATE/runtime_detection.yml +103 -0
  14. package/dist/extensions/pi-zentui/.github/dependabot.yml +47 -0
  15. package/dist/extensions/pi-zentui/.github/pull_request_template.md +34 -0
  16. package/dist/extensions/pi-zentui/.github/release.yml +18 -0
  17. package/dist/extensions/pi-zentui/.github/workflows/ci.yml +79 -0
  18. package/dist/extensions/pi-zentui/.github/workflows/publish.yml +63 -0
  19. package/dist/extensions/pi-zentui/AGENTS.md +107 -0
  20. package/dist/extensions/pi-zentui/CONTRIBUTING.md +68 -0
  21. package/dist/extensions/pi-zentui/LICENSE +21 -0
  22. package/dist/extensions/pi-zentui/README.md +188 -0
  23. package/dist/extensions/pi-zentui/SECURITY.md +18 -0
  24. package/dist/extensions/pi-zentui/assets/main-cover.png +0 -0
  25. package/dist/extensions/pi-zentui/assets/screenshots/editor-accent-rail.png +0 -0
  26. package/dist/extensions/pi-zentui/assets/screenshots/editor-minimalist.png +0 -0
  27. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode-copy-friendly.png +0 -0
  28. package/dist/extensions/pi-zentui/assets/screenshots/editor-opencode.png +0 -0
  29. package/dist/extensions/pi-zentui/assets/screenshots/user-message-compact.png +0 -0
  30. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed-copy-friendly.png +0 -0
  31. package/dist/extensions/pi-zentui/assets/screenshots/user-message-framed.png +0 -0
  32. package/dist/extensions/pi-zentui/assets/screenshots/user-message-labeled.png +0 -0
  33. package/dist/extensions/pi-zentui/assets/zentui.png +0 -0
  34. package/dist/extensions/pi-zentui/biome.json +31 -0
  35. package/dist/extensions/pi-zentui/docs/configuration.md +509 -0
  36. package/dist/extensions/pi-zentui/docs/footer-format.md +162 -0
  37. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-editor.ts +99 -0
  38. package/dist/extensions/pi-zentui/extensions/zentui/accent-rail-layout-patch.ts +457 -0
  39. package/dist/extensions/pi-zentui/extensions/zentui/completion-menu.ts +157 -0
  40. package/dist/extensions/pi-zentui/extensions/zentui/config.ts +2100 -0
  41. package/dist/extensions/pi-zentui/extensions/zentui/editor-metadata-format.ts +321 -0
  42. package/dist/extensions/pi-zentui/extensions/zentui/editor-transfer.ts +73 -0
  43. package/dist/extensions/pi-zentui/extensions/zentui/extension-status.ts +89 -0
  44. package/dist/extensions/pi-zentui/extensions/zentui/footer-format.ts +364 -0
  45. package/dist/extensions/pi-zentui/extensions/zentui/footer-layout.ts +122 -0
  46. package/dist/extensions/pi-zentui/extensions/zentui/footer.ts +812 -0
  47. package/dist/extensions/pi-zentui/extensions/zentui/format.ts +568 -0
  48. package/dist/extensions/pi-zentui/extensions/zentui/git.ts +384 -0
  49. package/dist/extensions/pi-zentui/extensions/zentui/icons.ts +252 -0
  50. package/dist/extensions/pi-zentui/extensions/zentui/index.ts +1406 -0
  51. package/dist/extensions/pi-zentui/extensions/zentui/interaction-summary.ts +1049 -0
  52. package/dist/extensions/pi-zentui/extensions/zentui/live-context.ts +75 -0
  53. package/dist/extensions/pi-zentui/extensions/zentui/minimalist-editor.ts +463 -0
  54. package/dist/extensions/pi-zentui/extensions/zentui/package-version.ts +651 -0
  55. package/dist/extensions/pi-zentui/extensions/zentui/project-refresh.ts +120 -0
  56. package/dist/extensions/pi-zentui/extensions/zentui/project-state.ts +59 -0
  57. package/dist/extensions/pi-zentui/extensions/zentui/prototype-patch-registry.ts +166 -0
  58. package/dist/extensions/pi-zentui/extensions/zentui/repository-root.ts +98 -0
  59. package/dist/extensions/pi-zentui/extensions/zentui/runtime.ts +841 -0
  60. package/dist/extensions/pi-zentui/extensions/zentui/selector-border.ts +93 -0
  61. package/dist/extensions/pi-zentui/extensions/zentui/session-lifecycle.ts +60 -0
  62. package/dist/extensions/pi-zentui/extensions/zentui/settings-command.ts +1796 -0
  63. package/dist/extensions/pi-zentui/extensions/zentui/settings-previews.ts +128 -0
  64. package/dist/extensions/pi-zentui/extensions/zentui/state.ts +94 -0
  65. package/dist/extensions/pi-zentui/extensions/zentui/style.ts +424 -0
  66. package/dist/extensions/pi-zentui/extensions/zentui/telemetry.ts +82 -0
  67. package/dist/extensions/pi-zentui/extensions/zentui/ui.ts +1290 -0
  68. package/dist/extensions/pi-zentui/extensions/zentui/user-message-osc.ts +248 -0
  69. package/dist/extensions/pi-zentui/extensions/zentui/user-message-styles.ts +215 -0
  70. package/dist/extensions/pi-zentui/extensions/zentui/user-message.ts +206 -0
  71. package/dist/extensions/pi-zentui/extensions/zentui/working-line-messages.ts +24 -0
  72. package/dist/extensions/pi-zentui/extensions/zentui/working-line-spinners.ts +54 -0
  73. package/dist/extensions/pi-zentui/extensions/zentui/working-line.ts +1433 -0
  74. package/dist/extensions/pi-zentui/package-lock.json +5311 -0
  75. package/dist/extensions/pi-zentui/package.json +60 -0
  76. package/dist/extensions/pi-zentui/test/accent-rail-editor.test.ts +232 -0
  77. package/dist/extensions/pi-zentui/test/accent-rail-layout-patch.test.ts +669 -0
  78. package/dist/extensions/pi-zentui/test/completion-menu.test.ts +265 -0
  79. package/dist/extensions/pi-zentui/test/config.test.ts +3119 -0
  80. package/dist/extensions/pi-zentui/test/editor-metadata-format.test.ts +405 -0
  81. package/dist/extensions/pi-zentui/test/editor-transfer.test.ts +260 -0
  82. package/dist/extensions/pi-zentui/test/editor-viewport-indicators.test.ts +1286 -0
  83. package/dist/extensions/pi-zentui/test/extension-compliance.test.ts +7196 -0
  84. package/dist/extensions/pi-zentui/test/extension-status.test.ts +182 -0
  85. package/dist/extensions/pi-zentui/test/fixtures/starship-nerd-font-symbols.toml +172 -0
  86. package/dist/extensions/pi-zentui/test/footer-format.test.ts +431 -0
  87. package/dist/extensions/pi-zentui/test/footer-layout.test.ts +113 -0
  88. package/dist/extensions/pi-zentui/test/footer-repository-path.test.ts +123 -0
  89. package/dist/extensions/pi-zentui/test/format.test.ts +865 -0
  90. package/dist/extensions/pi-zentui/test/git.test.ts +212 -0
  91. package/dist/extensions/pi-zentui/test/icons.test.ts +69 -0
  92. package/dist/extensions/pi-zentui/test/interaction-summary.test.ts +1402 -0
  93. package/dist/extensions/pi-zentui/test/live-context-integration.test.ts +378 -0
  94. package/dist/extensions/pi-zentui/test/live-context.test.ts +139 -0
  95. package/dist/extensions/pi-zentui/test/minimalist-editor.test.ts +811 -0
  96. package/dist/extensions/pi-zentui/test/package-version.test.ts +321 -0
  97. package/dist/extensions/pi-zentui/test/project-refresh.test.ts +250 -0
  98. package/dist/extensions/pi-zentui/test/project-state.test.ts +159 -0
  99. package/dist/extensions/pi-zentui/test/repository-root.test.ts +108 -0
  100. package/dist/extensions/pi-zentui/test/responsive-dependencies.test.ts +825 -0
  101. package/dist/extensions/pi-zentui/test/runtime.test.ts +183 -0
  102. package/dist/extensions/pi-zentui/test/session-lifecycle.test.ts +40 -0
  103. package/dist/extensions/pi-zentui/test/settings-command.test.ts +1381 -0
  104. package/dist/extensions/pi-zentui/test/settings-previews.test.ts +347 -0
  105. package/dist/extensions/pi-zentui/test/state.test.ts +84 -0
  106. package/dist/extensions/pi-zentui/test/telemetry-lifecycle-integration.test.ts +276 -0
  107. package/dist/extensions/pi-zentui/test/telemetry.test.ts +105 -0
  108. package/dist/extensions/pi-zentui/test/turn-summary-lifecycle.integration.test.ts +327 -0
  109. package/dist/extensions/pi-zentui/test/user-message-osc.test.ts +164 -0
  110. package/dist/extensions/pi-zentui/test/user-message-styles.test.ts +292 -0
  111. package/dist/extensions/pi-zentui/test/working-line-lifecycle.integration.test.ts +730 -0
  112. package/dist/extensions/pi-zentui/test/working-line-messages.test.ts +34 -0
  113. package/dist/extensions/pi-zentui/test/working-line.test.ts +1990 -0
  114. package/dist/extensions/pi-zentui/tsconfig.json +16 -0
  115. package/dist/extensions/tps.test.ts +38 -0
  116. package/dist/extensions/{pi-powerline-footer/tps.ts → tps.ts} +4 -0
  117. package/dist/skills/design-references/SKILL.md +31 -0
  118. package/dist/skills/design-references/references/clickhouse/DESIGN.md +544 -0
  119. package/dist/themes/adventure.json +84 -0
  120. package/dist/themes/adwaita-dark.json +84 -0
  121. package/dist/themes/arcoiris.json +84 -0
  122. package/dist/themes/arthur.json +84 -0
  123. package/dist/themes/atom.json +84 -0
  124. package/dist/themes/aura.json +84 -0
  125. package/dist/themes/black-metal-bathory.json +84 -0
  126. package/dist/themes/black-metal-burzum.json +84 -0
  127. package/dist/themes/black-metal-khold.json +84 -0
  128. package/dist/themes/box.json +84 -0
  129. package/dist/themes/brogrammer.json +84 -0
  130. package/dist/themes/carbonfox.json +84 -0
  131. package/dist/themes/catppuccin-mocha.json +84 -0
  132. package/dist/themes/citruszest.json +84 -0
  133. package/dist/themes/cursor-dark.json +84 -0
  134. package/dist/themes/cutie-pro.json +84 -0
  135. package/dist/themes/dark-modern.json +84 -0
  136. package/dist/themes/dark-pastel.json +84 -0
  137. package/dist/themes/dimmed-monokai.json +84 -0
  138. package/dist/themes/doom-peacock.json +84 -0
  139. package/dist/themes/dracula-plus.json +84 -0
  140. package/dist/themes/dracula.json +80 -0
  141. package/dist/themes/earthsong.json +84 -0
  142. package/dist/themes/everforest-dark-hard.json +84 -0
  143. package/dist/themes/everforest-dark.json +80 -0
  144. package/dist/themes/fahrenheit.json +84 -0
  145. package/dist/themes/flatland.json +84 -0
  146. package/dist/themes/flexoki-dark.json +84 -0
  147. package/dist/themes/front-end-delight.json +84 -0
  148. package/dist/themes/fun-forrest.json +84 -0
  149. package/dist/themes/galizur.json +84 -0
  150. package/dist/themes/github-dark-colorblind.json +84 -0
  151. package/dist/themes/github-dark-high-contrast.json +84 -0
  152. package/dist/themes/glacier.json +84 -0
  153. package/dist/themes/gruber-darker.json +84 -0
  154. package/dist/themes/gruvbox-dark-hard.json +84 -0
  155. package/dist/themes/gruvbox-dark.json +84 -0
  156. package/dist/themes/gruvbox-material.json +84 -0
  157. package/dist/themes/guezwhoz.json +84 -0
  158. package/dist/themes/hacktober.json +84 -0
  159. package/dist/themes/hardcore.json +84 -0
  160. package/dist/themes/havn-skumring.json +84 -0
  161. package/dist/themes/ic-orange-ppl.json +84 -0
  162. package/dist/themes/iterm2-smoooooth.json +84 -0
  163. package/dist/themes/iterm2-tango-dark.json +84 -0
  164. package/dist/themes/japanesque.json +84 -0
  165. package/dist/themes/jellybeans.json +84 -0
  166. package/dist/themes/kanagawa-wave.json +84 -0
  167. package/dist/themes/kurokula.json +84 -0
  168. package/dist/themes/later-this-evening.json +84 -0
  169. package/dist/themes/lovelace.json +84 -0
  170. package/dist/themes/material-darker.json +84 -0
  171. package/dist/themes/matte-black.json +84 -0
  172. package/dist/themes/mellow.json +84 -0
  173. package/dist/themes/miasma.json +84 -0
  174. package/dist/themes/nord.json +80 -0
  175. package/dist/themes/nvim-dark.json +84 -0
  176. package/dist/themes/one-dark.json +80 -0
  177. package/dist/themes/popping-and-locking.json +84 -0
  178. package/dist/themes/rose-pine.json +80 -0
  179. package/dist/themes/sea-shells.json +84 -0
  180. package/dist/themes/sleepy-hollow.json +84 -0
  181. package/dist/themes/smyck.json +84 -0
  182. package/dist/themes/solarized-dark.json +80 -0
  183. package/dist/themes/tokyo-night.json +80 -0
  184. package/dist/themes/tomorrow-night-bright.json +84 -0
  185. package/dist/themes/tomorrow-night-burns.json +84 -0
  186. package/dist/themes/tomorrow-night.json +84 -0
  187. package/dist/themes/twilight.json +84 -0
  188. package/dist/themes/vague.json +84 -0
  189. package/dist/themes/vesper.json +84 -0
  190. package/dist/themes/xcode-dark-hc.json +84 -0
  191. package/docs/custom-provider.md +0 -1
  192. package/docs/plans/auto-model-routing-extension.md +490 -0
  193. package/package.json +3 -3
  194. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +0 -27
  195. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +0 -687
  196. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +0 -16
  197. package/dist/extensions/pi-powerline-footer/README.md +0 -513
  198. package/dist/extensions/pi-powerline-footer/banner.png +0 -0
  199. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +0 -586
  200. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +0 -583
  201. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +0 -204
  202. package/dist/extensions/pi-powerline-footer/bash-mode/shell-session.ts +0 -286
  203. package/dist/extensions/pi-powerline-footer/bash-mode/transcript.ts +0 -108
  204. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +0 -60
  205. package/dist/extensions/pi-powerline-footer/cd-command.ts +0 -190
  206. package/dist/extensions/pi-powerline-footer/colors.ts +0 -70
  207. package/dist/extensions/pi-powerline-footer/context-usage.ts +0 -156
  208. package/dist/extensions/pi-powerline-footer/currency-rates.ts +0 -142
  209. package/dist/extensions/pi-powerline-footer/editor-composition.ts +0 -32
  210. package/dist/extensions/pi-powerline-footer/editor-performance.ts +0 -99
  211. package/dist/extensions/pi-powerline-footer/git-status.ts +0 -329
  212. package/dist/extensions/pi-powerline-footer/guide.ts +0 -220
  213. package/dist/extensions/pi-powerline-footer/icons.ts +0 -190
  214. package/dist/extensions/pi-powerline-footer/index.ts +0 -3428
  215. package/dist/extensions/pi-powerline-footer/lifecycle.ts +0 -10
  216. package/dist/extensions/pi-powerline-footer/package.json +0 -49
  217. package/dist/extensions/pi-powerline-footer/paths.ts +0 -22
  218. package/dist/extensions/pi-powerline-footer/powerline-config.ts +0 -446
  219. package/dist/extensions/pi-powerline-footer/presets.ts +0 -102
  220. package/dist/extensions/pi-powerline-footer/queue/store.ts +0 -313
  221. package/dist/extensions/pi-powerline-footer/queue/types.ts +0 -53
  222. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +0 -29
  223. package/dist/extensions/pi-powerline-footer/segments.ts +0 -570
  224. package/dist/extensions/pi-powerline-footer/separators.ts +0 -57
  225. package/dist/extensions/pi-powerline-footer/session-usage.ts +0 -44
  226. package/dist/extensions/pi-powerline-footer/shortcuts.ts +0 -61
  227. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +0 -1905
  228. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +0 -144
  229. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +0 -156
  230. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +0 -344
  231. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +0 -48
  232. package/dist/extensions/pi-powerline-footer/tests/editor-performance.test.ts +0 -43
  233. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +0 -136
  234. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +0 -39
  235. package/dist/extensions/pi-powerline-footer/tests/git-optional-locks.test.ts +0 -64
  236. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +0 -92
  237. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +0 -129
  238. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +0 -116
  239. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +0 -79
  240. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +0 -63
  241. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +0 -65
  242. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +0 -14
  243. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +0 -173
  244. package/dist/extensions/pi-powerline-footer/tests/session-usage.test.ts +0 -47
  245. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +0 -37
  246. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +0 -76
  247. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +0 -249
  248. package/dist/extensions/pi-powerline-footer/tests/tps.test.ts +0 -39
  249. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +0 -237
  250. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +0 -199
  251. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +0 -317
  252. package/dist/extensions/pi-powerline-footer/theme.example.json +0 -24
  253. package/dist/extensions/pi-powerline-footer/theme.json +0 -12
  254. package/dist/extensions/pi-powerline-footer/theme.ts +0 -241
  255. package/dist/extensions/pi-powerline-footer/token-stats.ts +0 -304
  256. package/dist/extensions/pi-powerline-footer/tsconfig.json +0 -13
  257. package/dist/extensions/pi-powerline-footer/types.ts +0 -235
  258. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +0 -34
  259. package/dist/extensions/pi-powerline-footer/welcome.ts +0 -709
  260. package/dist/extensions/pi-powerline-footer/working-vibes.ts +0 -721
  261. package/dist/extensions/preview-tools-disabled.test.ts +0 -37
  262. package/dist/extensions/preview-tools-disabled.ts +0 -11
  263. package/dist/themes/powerline-footer/theme.json +0 -33
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Starship-style footer format string parser and renderer.
3
+ *
4
+ * Pure module (no TUI/config imports) so it is fully unit-testable.
5
+ *
6
+ * Supports conditional groups: `( ... )` is dropped when every nested
7
+ * variable (and nested group) renders empty.
8
+ */
9
+
10
+ export type FormatToken =
11
+ | { kind: "text"; value: string }
12
+ | { kind: "var"; name: string }
13
+ | { kind: "fill" }
14
+ | { kind: "group"; tokens: FormatToken[] };
15
+
16
+ export type CompactBoundaryKind = "space" | "separator";
17
+
18
+ export type CompactFormatChunk =
19
+ | { kind: "tokens"; tokens: FormatToken[]; boundary: CompactBoundaryKind }
20
+ | { kind: "extensions"; boundary: CompactBoundaryKind };
21
+
22
+ const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
23
+
24
+ /**
25
+ * Tokenize a format string into text/var/fill/group tokens.
26
+ *
27
+ * `$name` and `${name}` both produce a variable token. A variable named
28
+ * `fill` becomes a fill token instead. Parentheses form conditional groups
29
+ * that drop entirely when all nested vars are empty.
30
+ */
31
+ export function parseFooterFormat(format: string): FormatToken[] {
32
+ if (!format) return [];
33
+ return parseTokenSlice(format, 0, format.length, true).tokens;
34
+ }
35
+
36
+ function parseTokenSlice(
37
+ format: string,
38
+ start: number,
39
+ end: number,
40
+ topLevel = false,
41
+ ): { tokens: FormatToken[]; nextIndex: number } {
42
+ const tokens: FormatToken[] = [];
43
+ let index = start;
44
+ let textStart = start;
45
+
46
+ const flushText = (until: number) => {
47
+ if (until > textStart) {
48
+ tokens.push({ kind: "text", value: format.slice(textStart, until) });
49
+ }
50
+ };
51
+
52
+ while (index < end) {
53
+ const ch = format[index];
54
+
55
+ if (ch === "(") {
56
+ flushText(index);
57
+ const nested = parseTokenSlice(format, index + 1, end, false);
58
+ tokens.push({ kind: "group", tokens: nested.tokens });
59
+ index = nested.nextIndex;
60
+ textStart = index;
61
+ continue;
62
+ }
63
+
64
+ if (ch === ")") {
65
+ // Nested groups close on `)`. Unmatched top-level `)` is literal text so
66
+ // trailing tokens like `$cwd) $tokens` are not discarded.
67
+ if (topLevel) {
68
+ index += 1;
69
+ continue;
70
+ }
71
+ flushText(index);
72
+ return { tokens, nextIndex: index + 1 };
73
+ }
74
+
75
+ if (ch === "$") {
76
+ TOKEN_REGEX.lastIndex = index;
77
+ const match = TOKEN_REGEX.exec(format);
78
+ if (match && match.index === index && match.index < end) {
79
+ const full = match[0];
80
+ const matchEnd = match.index + full.length;
81
+ if (matchEnd > end) {
82
+ index += 1;
83
+ continue;
84
+ }
85
+ flushText(index);
86
+ const name = match[1] ?? match[2] ?? "";
87
+ if (name === "fill") {
88
+ tokens.push({ kind: "fill" });
89
+ } else {
90
+ tokens.push({ kind: "var", name });
91
+ }
92
+ index = matchEnd;
93
+ textStart = index;
94
+ continue;
95
+ }
96
+ }
97
+
98
+ index += 1;
99
+ }
100
+
101
+ flushText(end);
102
+ return { tokens, nextIndex: end };
103
+ }
104
+
105
+ /**
106
+ * Render tokens into `{ left, middle, right }` based on `$fill` markers.
107
+ *
108
+ * - No fill: everything → `left`; `middle` and `right` are `""`.
109
+ * - One fill: tokens before → `left`, tokens after → `right`; `middle` is `""`.
110
+ * - Two fills: before the first → `left`, between the two → `middle`
111
+ * (centered by the caller via the existing middle-zone logic), after the
112
+ * second → `right`.
113
+ * - Additional fills beyond the first two are ignored.
114
+ * - `$fill` inside a group is ignored (renders empty).
115
+ *
116
+ * Text tokens contribute their `value` verbatim (unstyled/plain); var tokens
117
+ * contribute `renderVariable(name)` (already styled by caller). No automatic
118
+ * spaces are inserted — the user controls all spacing.
119
+ */
120
+ export function renderFormatSplit(
121
+ tokens: FormatToken[],
122
+ renderVariable: (name: string) => string,
123
+ ): { left: string; middle: string; right: string } {
124
+ const fillIndices = findTopLevelFillIndices(tokens);
125
+
126
+ if (fillIndices.length === 0) {
127
+ return {
128
+ left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
129
+ middle: "",
130
+ right: "",
131
+ };
132
+ }
133
+
134
+ const first = fillIndices[0];
135
+ const second = fillIndices[1];
136
+
137
+ if (first === undefined) {
138
+ return {
139
+ left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
140
+ middle: "",
141
+ right: "",
142
+ };
143
+ }
144
+
145
+ if (second === undefined) {
146
+ return {
147
+ left: renderTokenSlice(tokens, 0, first, renderVariable),
148
+ middle: "",
149
+ right: renderTokenSlice(tokens, first + 1, tokens.length, renderVariable),
150
+ };
151
+ }
152
+
153
+ return {
154
+ left: renderTokenSlice(tokens, 0, first, renderVariable),
155
+ middle: renderTokenSlice(tokens, first + 1, second, renderVariable),
156
+ right: renderTokenSlice(tokens, second + 1, tokens.length, renderVariable),
157
+ };
158
+ }
159
+
160
+ export function compileCompactFormat(tokens: FormatToken[]): CompactFormatChunk[] {
161
+ const chunks: CompactFormatChunk[] = [];
162
+ let current: FormatToken[] = [];
163
+ let incomingBoundary: CompactBoundaryKind = "space";
164
+
165
+ const flush = () => {
166
+ const normalized = trimBoundaryWhitespace(current);
167
+ current = [];
168
+ if (normalized.length === 0) return;
169
+ if (
170
+ normalized.length === 1 &&
171
+ normalized[0]?.kind === "var" &&
172
+ normalized[0].name === "extensions"
173
+ ) {
174
+ chunks.push({ kind: "extensions", boundary: incomingBoundary });
175
+ return;
176
+ }
177
+ chunks.push({ kind: "tokens", tokens: normalized, boundary: incomingBoundary });
178
+ };
179
+
180
+ for (const token of tokens) {
181
+ if (token.kind === "var" && (token.name === "wrap" || token.name === "wrap_sep")) {
182
+ flush();
183
+ incomingBoundary = token.name === "wrap_sep" ? "separator" : "space";
184
+ continue;
185
+ }
186
+ if (token.kind === "fill") continue;
187
+ current.push(token);
188
+ }
189
+ flush();
190
+ return chunks;
191
+ }
192
+
193
+ function trimBoundaryWhitespace(tokens: FormatToken[]): FormatToken[] {
194
+ const result = tokens.map((token) => (token.kind === "text" ? { ...token } : token));
195
+ while (result[0]?.kind === "text") {
196
+ result[0].value = result[0].value.replace(/^\s+/, "");
197
+ if (result[0].value) break;
198
+ result.shift();
199
+ }
200
+ while (result.at(-1)?.kind === "text") {
201
+ const last = result.at(-1);
202
+ if (last?.kind !== "text") break;
203
+ last.value = last.value.replace(/\s+$/, "");
204
+ if (last.value) break;
205
+ result.pop();
206
+ }
207
+ return result;
208
+ }
209
+
210
+ export function renderFormatTokens(
211
+ tokens: FormatToken[],
212
+ renderVariable: (name: string) => string,
213
+ ): string {
214
+ return renderTokenSlice(tokens, 0, tokens.length, renderVariable);
215
+ }
216
+
217
+ export function collectFooterFormatReferences(
218
+ tokens: FormatToken[],
219
+ aliases: Record<string, string> = {},
220
+ ): Set<string> {
221
+ const references = new Set<string>();
222
+ const visit = (items: FormatToken[]) => {
223
+ for (const token of items) {
224
+ if (token.kind === "group") {
225
+ visit(token.tokens);
226
+ continue;
227
+ }
228
+ if (token.kind !== "var") continue;
229
+ const canonical = aliases[token.name] ?? token.name;
230
+ if (canonical !== "wrap" && canonical !== "wrap_sep" && canonical !== "extensions") {
231
+ references.add(canonical);
232
+ }
233
+ }
234
+ };
235
+ visit(tokens);
236
+ return references;
237
+ }
238
+
239
+ function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
240
+ const fillIndices: number[] = [];
241
+ for (let index = 0; index < tokens.length; index++) {
242
+ if (tokens[index]?.kind === "fill") fillIndices.push(index);
243
+ }
244
+ return fillIndices;
245
+ }
246
+
247
+ function renderTokenSlice(
248
+ tokens: FormatToken[],
249
+ start: number,
250
+ end: number,
251
+ renderVariable: (name: string) => string,
252
+ ): string {
253
+ let result = "";
254
+ for (let i = start; i < end; i++) {
255
+ const token = tokens[i];
256
+ if (!token) continue;
257
+ result += renderToken(token, renderVariable);
258
+ }
259
+ return result;
260
+ }
261
+
262
+ function renderToken(token: FormatToken, renderVariable: (name: string) => string): string {
263
+ if (token.kind === "text") return token.value;
264
+ if (token.kind === "var") return renderVariable(token.name);
265
+ if (token.kind === "fill") return "";
266
+ // group
267
+ const rendered = token.tokens.map((child) => renderToken(child, renderVariable)).join("");
268
+ if (isGroupEmpty(token, renderVariable)) return "";
269
+ return rendered;
270
+ }
271
+
272
+ /**
273
+ * Separator vars only style gaps between content; they must not keep a group
274
+ * alive when every real content var is empty (e.g. `($sep$tokens)` drops if
275
+ * tokens is empty).
276
+ */
277
+ const NON_CONTENT_VARS = new Set(["sep", "separator"]);
278
+
279
+ /**
280
+ * A group is empty iff every content var leaf is empty and every nested group
281
+ * is empty. Text-only groups (no vars) always show. `$sep` / `$separator` are
282
+ * ignored for emptiness so orphan themed pipes do not force a group to render.
283
+ */
284
+ function isGroupEmpty(
285
+ group: FormatToken & { kind: "group" },
286
+ renderVariable: (name: string) => string,
287
+ ): boolean {
288
+ let sawContentVarOrGroup = false;
289
+ for (const child of group.tokens) {
290
+ if (child.kind === "var") {
291
+ if (NON_CONTENT_VARS.has(child.name)) continue;
292
+ sawContentVarOrGroup = true;
293
+ if (renderVariable(child.name) !== "") return false;
294
+ } else if (child.kind === "group") {
295
+ sawContentVarOrGroup = true;
296
+ if (!isGroupEmpty(child, renderVariable)) return false;
297
+ }
298
+ }
299
+ // Text-only groups (or groups with only $sep) are shown only when no content vars.
300
+ // Groups that only contain $sep still count as empty so they drop.
301
+ return sawContentVarOrGroup || groupOnlyNonContentVars(group);
302
+ }
303
+
304
+ function groupOnlyNonContentVars(group: FormatToken & { kind: "group" }): boolean {
305
+ let sawSep = false;
306
+ for (const child of group.tokens) {
307
+ if (child.kind === "text") {
308
+ if (child.value.trim() !== "") return false;
309
+ continue;
310
+ }
311
+ if (child.kind === "var") {
312
+ if (!NON_CONTENT_VARS.has(child.name)) return false;
313
+ sawSep = true;
314
+ continue;
315
+ }
316
+ if (child.kind === "group") return false;
317
+ if (child.kind === "fill") continue;
318
+ }
319
+ return sawSep;
320
+ }
321
+
322
+ /** One optional SGR sequence (`\x1b[…m`). */
323
+ const ANSI_ONE_SRC = "\u001b\\[[0-9;]*m";
324
+
325
+ /**
326
+ * One ` | ` separator unit, plain or with a single ANSI wrapper on either side
327
+ * of the spaces/pipe (matches `renderStyle(..., " | ")` output).
328
+ */
329
+ const SEP_UNIT_SRC = `(?:${ANSI_ONE_SRC})?\\s+\\|\\s+(?:${ANSI_ONE_SRC})?`;
330
+
331
+ /**
332
+ * Join non-empty parts with a separator (segment-mode style).
333
+ * Useful when building right-side metrics without orphan pipes.
334
+ */
335
+ export function joinNonEmpty(parts: string[], separator: string): string {
336
+ return parts.filter(Boolean).join(separator);
337
+ }
338
+
339
+ /**
340
+ * Tidy a rendered format left/middle/right slice:
341
+ * - collapse repeated pipe separators (plain or simple ANSI-wrapped) into one
342
+ * - strip leading/trailing pipe separators
343
+ * - strip leading/trailing whitespace
344
+ * - drop slices that are only ANSI / whitespace after cleanup
345
+ */
346
+ export function stripOrphanSeparators(rendered: string): string {
347
+ if (!rendered) return rendered;
348
+
349
+ // Collapse consecutive separator units, keeping the first (preserves themed color).
350
+ const consecutive = new RegExp(`(${SEP_UNIT_SRC})(?:${SEP_UNIT_SRC})+`, "g");
351
+ let result = rendered.replace(consecutive, "$1");
352
+
353
+ // Strip leading / trailing separator units.
354
+ result = result.replace(new RegExp(`^(?:${SEP_UNIT_SRC})+`), "");
355
+ result = result.replace(new RegExp(`(?:${SEP_UNIT_SRC})+$`), "");
356
+
357
+ // Strip leading / trailing plain whitespace left by empty groups.
358
+ result = result.replace(/^\s+/, "").replace(/\s+$/, "");
359
+
360
+ // Pure ANSI (or empty) leftovers are not useful content.
361
+ if (result.replace(new RegExp(ANSI_ONE_SRC, "g"), "").trim() === "") return "";
362
+
363
+ return result;
364
+ }
@@ -0,0 +1,122 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { CompactFooterMaxLines } from "./config";
3
+ import type { CompactBoundaryKind } from "./footer-format";
4
+
5
+ export type FooterZones = {
6
+ left: string;
7
+ middle: string;
8
+ right: string;
9
+ };
10
+
11
+ export function fullFooterFitsAligned(zones: FooterZones, innerWidth: number): boolean {
12
+ const leftWidth = visibleWidth(zones.left);
13
+ const middleWidth = visibleWidth(zones.middle);
14
+ const rightWidth = visibleWidth(zones.right);
15
+ if (middleWidth === 0) {
16
+ return leftWidth + rightWidth + (leftWidth > 0 && rightWidth > 0 ? 1 : 0) <= innerWidth;
17
+ }
18
+
19
+ const gapWidth = innerWidth - leftWidth - rightWidth;
20
+ if (gapWidth < middleWidth) return false;
21
+ const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
22
+ const rightPadding = gapWidth - middleWidth - leftPadding;
23
+ return (leftWidth === 0 || leftPadding >= 1) && (rightWidth === 0 || rightPadding >= 1);
24
+ }
25
+
26
+ function joinZones(parts: string[]): string {
27
+ return parts.filter(Boolean).join(" ");
28
+ }
29
+
30
+ function validRows(rows: string[], innerWidth: number): string[] | undefined {
31
+ const nonEmpty = rows.filter(Boolean);
32
+ if (nonEmpty.length === 0 || nonEmpty.length > 2) return undefined;
33
+ return nonEmpty.every((row) => visibleWidth(row) <= innerWidth) ? nonEmpty : undefined;
34
+ }
35
+
36
+ /** Prefer project identity alone, with middle/right status on the second row. */
37
+ export function reflowFullFooter(zones: FooterZones, innerWidth: number): string[] | undefined {
38
+ const preferred = validRows([zones.left, joinZones([zones.middle, zones.right])], innerWidth);
39
+ if (preferred) return preferred;
40
+ return validRows([joinZones([zones.left, zones.middle]), zones.right], innerWidth);
41
+ }
42
+
43
+ export function compactChunkBudget(innerWidth: number): number {
44
+ return Math.max(8, Math.floor((innerWidth - 1) / 2));
45
+ }
46
+
47
+ export type CompactLayoutChunk = {
48
+ text: string;
49
+ boundary: CompactBoundaryKind;
50
+ };
51
+
52
+ type PackedRow = {
53
+ text: string;
54
+ endsWithRendererEllipsis: boolean;
55
+ };
56
+
57
+ function fitChunk(chunk: string, innerWidth: number): PackedRow {
58
+ if (visibleWidth(chunk) <= innerWidth) {
59
+ return { text: chunk, endsWithRendererEllipsis: false };
60
+ }
61
+ return {
62
+ text: truncateToWidth(chunk, innerWidth, "…"),
63
+ endsWithRendererEllipsis: true,
64
+ };
65
+ }
66
+
67
+ function appendOmissionMarker(row: PackedRow, innerWidth: number): PackedRow {
68
+ if (row.endsWithRendererEllipsis) return row;
69
+ if (innerWidth <= 1) return { text: "…", endsWithRendererEllipsis: true };
70
+ return {
71
+ text: `${truncateToWidth(row.text, innerWidth - 1, "")}…`,
72
+ endsWithRendererEllipsis: true,
73
+ };
74
+ }
75
+
76
+ export function packCompactChunks(
77
+ chunks: CompactLayoutChunk[],
78
+ innerWidth: number,
79
+ maxLines: CompactFooterMaxLines,
80
+ separator: string,
81
+ ): string[] {
82
+ if (innerWidth <= 0) return [""];
83
+ const content = chunks
84
+ .map((chunk) => ({ ...chunk, text: chunk.text.trim() }))
85
+ .filter((chunk) => chunk.text.length > 0);
86
+ if (content.length === 0) return [""];
87
+
88
+ const finiteLimit = maxLines === "unlimited" ? Number.POSITIVE_INFINITY : maxLines;
89
+ const rows: PackedRow[] = [];
90
+ let current: PackedRow | undefined;
91
+ let omitted = false;
92
+
93
+ for (const chunk of content) {
94
+ const fitted = fitChunk(chunk.text, innerWidth);
95
+ if (!current) {
96
+ current = fitted;
97
+ continue;
98
+ }
99
+
100
+ const join = chunk.boundary === "separator" ? separator : " ";
101
+ const candidate = `${current.text}${join}${fitted.text}`;
102
+ if (visibleWidth(candidate) <= innerWidth) {
103
+ current = {
104
+ text: candidate,
105
+ endsWithRendererEllipsis: fitted.endsWithRendererEllipsis,
106
+ };
107
+ continue;
108
+ }
109
+
110
+ if (rows.length + 1 < finiteLimit) {
111
+ rows.push(current);
112
+ current = fitted;
113
+ continue;
114
+ }
115
+
116
+ omitted = true;
117
+ break;
118
+ }
119
+
120
+ if (current) rows.push(omitted ? appendOmissionMarker(current, innerWidth) : current);
121
+ return rows.map((row) => truncateToWidth(row.text, innerWidth, ""));
122
+ }