one-code-extension 0.1.4 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -217
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -15,7 +15,10 @@
15
15
  * every line goes through `truncateLine` — never skip it.
16
16
  */
17
17
 
18
- import { NOTIFICATION_HEADER, NOTIFICATION_PREFIX } from "./notifications.ts";
18
+ import { notificationBody } from "./notifications.ts";
19
+ import { fitPainted, hardWrapColumns, sliceColumns, visibleWidth } from "./text-width.ts";
20
+ export { notificationBody };
21
+ export { visibleWidth } from "./text-width.ts";
19
22
 
20
23
  /** The structural subset of pi-tui's Component that pi's renderers require. */
21
24
  export interface TuiComponent {
@@ -30,6 +33,22 @@ export interface ThemeLike {
30
33
  italic(text: string): string;
31
34
  }
32
35
 
36
+ /**
37
+ * A ctx that is live and has a UI, else undefined. pi invalidates an
38
+ * extension's ctx when the session is replaced (/clear, /new, /resume —
39
+ * findings §8): every getter on it throws from then on, and a widget's timer or
40
+ * a child's late event can still fire afterwards. Painting through this turns
41
+ * the stale ctx into "no UI" instead of an uncaught throw in a timer callback,
42
+ * which took the whole process down once (SUBAGENT-REVIEW H1).
43
+ */
44
+ export function liveUiCtx<T extends { hasUI: boolean }>(ctx: T | undefined): T | undefined {
45
+ try {
46
+ return ctx?.hasUI ? ctx : undefined;
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
51
+
33
52
  /**
34
53
  * A `paint(color, text)` closure that degrades to the plain text whenever the
35
54
  * theme is missing, lacks `fg`, or `fg` throws — the guard every `ctx.ui.custom`
@@ -46,49 +65,208 @@ export function safeThemePaint(theme: unknown): (color: string, text: string) =>
46
65
  };
47
66
  }
48
67
 
68
+ /** The `inverse(text)` counterpart to safeThemePaint, with the same degrade-to-plain guard. */
69
+ export function safeThemeInverse(theme: unknown): (text: string) => string {
70
+ return (text: string) => {
71
+ const themed = theme as { inverse?(t: string): string } | undefined;
72
+ try {
73
+ return themed?.inverse ? themed.inverse(text) : text;
74
+ } catch {
75
+ return text;
76
+ }
77
+ };
78
+ }
79
+
80
+ /** The `bold(text)` counterpart to safeThemePaint, with the same degrade-to-plain guard. */
81
+ export function safeThemeBold(theme: unknown): (text: string) => string {
82
+ return (text: string) => {
83
+ const themed = theme as { bold?(t: string): string } | undefined;
84
+ try {
85
+ return themed?.bold ? themed.bold(text) : text;
86
+ } catch {
87
+ return text;
88
+ }
89
+ };
90
+ }
91
+
92
+ /** Humane elapsed time, Claude Code style: 45s, 1m 7s, 2h 5m. */
93
+ export function formatDuration(startedAt?: number, finishedAt?: number, now?: number): string {
94
+ if (startedAt === undefined) return "";
95
+ const end = finishedAt ?? now ?? startedAt;
96
+ const total = Math.max(0, Math.round((end - startedAt) / 1000));
97
+ if (total < 60) return `${total}s`;
98
+ if (total < 3600) return `${Math.floor(total / 60)}m ${total % 60}s`;
99
+ return `${Math.floor(total / 3600)}h ${Math.floor((total % 3600) / 60)}m`;
100
+ }
101
+
102
+ /** `2 shells`, `1 shell` — a count with its (s-pluralized) noun. */
103
+ export function countNoun(count: number, noun: string): string {
104
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
105
+ }
106
+
107
+ /** Right-align plain text within `width` terminal columns. */
108
+ export function alignRight(text: string, width: number): string {
109
+ const length = visibleWidth(text);
110
+ if (length >= width) return sliceColumns(text, Math.max(0, width)).text;
111
+ return " ".repeat(width - length) + text;
112
+ }
113
+
49
114
  /**
50
- * Cut a painted line to `width` visible columns without splitting ANSI escape
51
- * sequences, ending with an ellipsis and a reset so truncation cannot leak a
52
- * colour into the next line. pi-tui *crashes* the whole app on an overwide
53
- * line ("Rendered line exceeds terminal width"), and only validates a
54
- * component when its output changes — so an overflow can hide in static
55
- * content for weeks, then kill pi the moment something makes it re-render.
115
+ * setWidget factory for a one-line, dim, right-aligned note (Claude Code's
116
+ * above-input chrome: the context token counter, the effort-changed note).
117
+ * The one-column right margin matches the paddingX pi gives string widgets.
56
118
  */
57
- const ANSI_AT = /\x1b\[[0-9;]*m/y;
119
+ export function dimRightAlignedWidget(text: string): (tui: unknown, theme: unknown) => TuiComponent {
120
+ return (_tui, theme) => {
121
+ const paint = safeThemePaint(theme);
122
+ return linesComponent((width) => [paint("dim", alignRight(text, Math.max(1, width - 1)))]);
123
+ };
124
+ }
58
125
 
59
- export function truncateLine(line: string, width: number): string {
126
+ /** Strikethrough via raw SGR 9/29 — pi themes have no strike paint. */
127
+ export function strike(text: string): string {
128
+ return `\x1b[9m${text}\x1b[29m`;
129
+ }
130
+
131
+ /**
132
+ * Height for a bounded panel that docks below the transcript (like Claude
133
+ * Code's /plugins and /skills): capped at `max` so the conversation stays
134
+ * visible, at least `min` when the terminal has room — but never taller than
135
+ * the terminal itself, so a short pane (< min+2 rows) shrinks to fit instead of
136
+ * overflowing.
137
+ */
138
+ export function boundedDockHeight(terminalRows: number, max: number, min = 12): number {
139
+ const avail = Math.max(1, terminalRows - 2);
140
+ return avail <= min ? avail : Math.min(avail, max);
141
+ }
142
+
143
+ /** Cut plain (unpainted) text to `width` terminal columns, with an ellipsis. */
144
+ export function cutPlainText(text: string, width: number): string {
60
145
  if (width <= 0) return "";
61
- // Raw length bounds visible width (escapes only ever add), so a line whose
62
- // raw length fits needs no scan — the hot path for nearly every line.
63
- if (line.length <= width) return line;
64
- let visible = 0;
65
- for (let i = 0; i < line.length; ) {
66
- ANSI_AT.lastIndex = i;
67
- const escape = ANSI_AT.exec(line);
68
- if (escape) {
69
- i += escape[0].length;
70
- continue;
71
- }
72
- visible++;
73
- i++;
146
+ if (visibleWidth(text) <= width) return text;
147
+ // Reserve one column for the ellipsis (itself width 1).
148
+ return `${sliceColumns(text, Math.max(0, width - 1)).text}…`;
149
+ }
150
+
151
+ /** Cut then pad plain (unpainted) text to exactly `width` terminal columns. */
152
+ export function padPlainText(text: string, width: number): string {
153
+ const shortened = cutPlainText(text, width);
154
+ return shortened + " ".repeat(Math.max(0, width - visibleWidth(shortened)));
155
+ }
156
+
157
+ /** First non-empty line of a possibly-multiline string, trimmed. */
158
+ export function firstNonEmptyLine(text: string): string {
159
+ return text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
160
+ }
161
+
162
+ /**
163
+ * Full-width rule that separates a docked panel from the transcript above it,
164
+ * the way Claude Code frames its /skills, /plugins and /mcp menus. Paint takes
165
+ * a (color, text) painter; the "border" theme token gives a visible-but-calm line.
166
+ */
167
+ export function panelTopRule(paint: (color: string, text: string) => string, width: number): string {
168
+ return paint("border", "─".repeat(Math.max(0, width)));
169
+ }
170
+
171
+ /**
172
+ * A rounded, bordered single-line search input, Claude Code style. Returns three
173
+ * lines (top border, the input row, bottom border), already indented one column.
174
+ * The placeholder shows dim until the user types. `paint` colors the border with
175
+ * the "border" token; the query text stays in the default foreground.
176
+ */
177
+ export function searchBoxLines(
178
+ query: string,
179
+ placeholder: string,
180
+ paint: (color: string, text: string) => string,
181
+ width: number,
182
+ ): string[] {
183
+ const boxWidth = Math.max(12, width - 2); // one column of margin each side
184
+ const inner = boxWidth - 2; // space between the vertical borders
185
+ const label = query ? `⌕ ${query}` : `⌕ ${placeholder}`;
186
+ const cut = cutPlainText(label, inner - 2); // one space padding each side
187
+ const content = ` ${cut}${" ".repeat(Math.max(0, inner - 2 - visibleWidth(cut)))} `;
188
+ const v = paint("border", "│");
189
+ return [
190
+ ` ${paint("border", `╭${"─".repeat(inner)}╮`)}`,
191
+ ` ${v}${query ? content : paint("dim", content)}${v}`,
192
+ ` ${paint("border", `╰${"─".repeat(inner)}╯`)}`,
193
+ ];
194
+ }
195
+
196
+ /** A run of rendered lines that either can (`selectable`) or cannot host the cursor. */
197
+ export interface RenderBlock {
198
+ lines: string[];
199
+ selectable: boolean;
200
+ }
201
+
202
+ /**
203
+ * Window a list of blocks so the block holding the Nth selectable row (`cursor`)
204
+ * stays visible within `budget` lines, returning the visible lines and how many
205
+ * blocks fell off the bottom. Group headers ride along as non-selectable blocks.
206
+ * The cursor's own block is always shown even when it alone exceeds the budget,
207
+ * so a tall selected row never scrolls itself off screen.
208
+ */
209
+ export function windowBlocks(blocks: RenderBlock[], cursor: number, budget: number): { lines: string[]; more: number } {
210
+ const selectableIndexes = blocks.map((b, i) => (b.selectable ? i : -1)).filter((i) => i >= 0);
211
+ const cursorBlock = selectableIndexes[cursor] ?? 0;
212
+ const linesBetween = (from: number, to: number) => blocks.slice(from, to + 1).reduce((n, b) => n + b.lines.length, 0);
213
+ let start = 0;
214
+ while (start < cursorBlock && linesBetween(start, cursorBlock) > budget) start++;
215
+ const lines: string[] = [];
216
+ let index = start;
217
+ for (; index < blocks.length; index++) {
218
+ if (lines.length > 0 && lines.length + blocks[index].lines.length > budget) break;
219
+ lines.push(...blocks[index].lines);
74
220
  }
75
- if (visible <= width) return line;
76
-
77
- let out = "";
78
- let used = 0;
79
- for (let i = 0; i < line.length && used < width - 1; ) {
80
- ANSI_AT.lastIndex = i;
81
- const escape = ANSI_AT.exec(line);
82
- if (escape) {
83
- out += escape[0];
84
- i += escape[0].length;
85
- continue;
86
- }
87
- out += line[i];
88
- used++;
89
- i++;
221
+ return { lines, more: blocks.length - index };
222
+ }
223
+
224
+ /**
225
+ * Split a row into a left half padded against a right-aligned annotation
226
+ * (two-space gap), fusing into one cut line when the left share would drop
227
+ * below readability. The single home of this layout rule — strip rows, pane
228
+ * rows, and transcript headers all build on it.
229
+ */
230
+ export function splitRow(left: string, right: string, width: number): { fused: string } | { left: string; right: string } {
231
+ const rightWidth = visibleWidth(right);
232
+ const leftWidth = width - rightWidth - 2;
233
+ if (rightWidth === 0 || leftWidth < 8) return { fused: cutPlainText(`${left} ${right}`.trimEnd(), width) };
234
+ return { left: padPlainText(left, leftWidth), right };
235
+ }
236
+
237
+ /** splitRow flattened to one plain string, exactly `width` wide. */
238
+ export function splitCell(left: string, right: string, width: number): string {
239
+ const row = splitRow(left, right, width);
240
+ return "fused" in row ? padPlainText(row.fused, width) : `${row.left} ${row.right}`;
241
+ }
242
+
243
+ /** Hard-wrap plain text to `width` terminal columns, preserving blank lines. */
244
+ export function wrapPlainText(text: string, width: number): string[] {
245
+ const columns = Math.max(1, width);
246
+ const out: string[] = [];
247
+ for (const raw of text.replace(/\r\n/g, "\n").split("\n")) {
248
+ if (raw.length === 0) out.push("");
249
+ else out.push(...hardWrapColumns(raw, columns));
90
250
  }
91
- return `${out}\x1b[0m…`;
251
+ return out;
252
+ }
253
+
254
+ /**
255
+ * Cut a painted line to `width` terminal columns without splitting ANSI escape
256
+ * sequences or a wide/emoji grapheme, ending with an ellipsis and a reset so
257
+ * truncation cannot leak a colour into the next line. pi-tui *crashes* the
258
+ * whole app on an overwide line ("Rendered line exceeds terminal width") — and
259
+ * it measures columns, not code points: a CJK ideograph or an emoji is two
260
+ * columns wide (findings/TUI-REVIEW H1). It only validates a component when its
261
+ * output changes, so an overflow can hide in static content for weeks, then
262
+ * kill pi the moment something makes it re-render.
263
+ */
264
+ export function truncateLine(line: string, width: number): string {
265
+ if (width <= 0) return "";
266
+ if (visibleWidth(line) <= width) return line;
267
+ // Reserve one column for the ellipsis (itself width 1).
268
+ const { text } = fitPainted(line, Math.max(0, width - 1));
269
+ return `${text}\x1b[0m…`;
92
270
  }
93
271
 
94
272
  /**
@@ -117,6 +295,16 @@ export function linesComponent(build: (width: number) => string[]): TuiComponent
117
295
  };
118
296
  }
119
297
 
298
+ /**
299
+ * A single dim transcript line led by a dim mark ("✻ Cooked for 5m 12s",
300
+ * "※ recap: …") — the shared shape behind the turn-duration and recap
301
+ * display-only entries. Width-memoized via linesComponent.
302
+ */
303
+ export function dimMarkedLine(theme: unknown, mark: string, text: string): TuiComponent {
304
+ const paint = safeThemePaint(theme);
305
+ return linesComponent(() => [`${paint("dim", mark)} ${paint("dim", text)}`]);
306
+ }
307
+
120
308
  /**
121
309
  * Params commonly carrying the human-meaningful part of a tool call, in
122
310
  * priority order. Used when a tool does not supply its own `title`.
@@ -342,27 +530,13 @@ export function ccToolRenderers<TArgs = any, TDetails = any>(
342
530
  } catch {
343
531
  text = undefined;
344
532
  }
345
- text ??= textContent(result);
533
+ text ??= textContent(stripReminderBlocks(result));
346
534
  if (!text) return linesComponent(() => []);
347
535
  return linesComponent(() => resultLines(theme, text, options.expanded, context.isError, spec.maxCollapsedLines));
348
536
  },
349
537
  };
350
538
  }
351
539
 
352
- /**
353
- * Strip the `systemNotification` anti-confabulation framing for display: the
354
- * framing exists for the model, not the user (findings §14). Returns the body.
355
- */
356
- export function notificationBody(text: string): string {
357
- const lines = text.split("\n");
358
- if (lines[0]?.startsWith(NOTIFICATION_HEADER)) {
359
- let i = 1;
360
- while (i < lines.length && (lines[i].trim() === "" || lines[i].startsWith(NOTIFICATION_PREFIX))) i++;
361
- return lines.slice(i).join("\n").trim();
362
- }
363
- return text.trim();
364
- }
365
-
366
540
  /** True when a line is visually empty — ANSI codes (Box padding paints) don't count as content. */
367
541
  export function isBlankLine(line: string): boolean {
368
542
  return line.replace(/\x1b\[[0-9;]*m/g, "").trim() === "";
@@ -394,6 +568,58 @@ export interface CcWrapOptions<TArgs = any> {
394
568
  * because base renderers cast `lastComponent` to their own concrete classes
395
569
  * and would throw on our wrapper.
396
570
  */
571
+ /**
572
+ * One harness block as it is written into a stored tool result: the
573
+ * `<system-reminder>` frame, the raw `<total_tokens>` budget line, the raw
574
+ * `<new-diagnostics>` block. Model-facing only; every transcript view strips
575
+ * them (Claude Code shows none of these to the user).
576
+ */
577
+ const HARNESS_BLOCK = String.raw`<system-reminder>[\s\S]*?</system-reminder>|<total_tokens>[^<]*</total_tokens>|<new-diagnostics>[\s\S]*?</new-diagnostics>`;
578
+ /** A text block that IS a harness block (the injector appends them as blocks of their own). */
579
+ const HARNESS_BLOCK_ONLY = new RegExp(String.raw`^\s*(?:${HARNESS_BLOCK})\s*$`);
580
+ /** A run of harness blocks at the END of a text block (the same bytes after a merge or a `suffix`). */
581
+ const HARNESS_TAIL = new RegExp(String.raw`(?:\s*(?:${HARNESS_BLOCK}))+\s*$`);
582
+
583
+ /**
584
+ * Display text with the harness blocks that were appended to it removed. Only
585
+ * a block that stands alone or closes the text is stripped — never one quoted
586
+ * in the middle of real output (a file that documents these tags reads back
587
+ * intact). Returns "" when nothing but harness blocks remained.
588
+ */
589
+ export function stripHarnessText(text: string): string {
590
+ if (HARNESS_BLOCK_ONLY.test(text)) return "";
591
+ return text.replace(HARNESS_TAIL, "");
592
+ }
593
+
594
+ /**
595
+ * A tool result with the harness blocks removed from its content, for display.
596
+ * One-shot reminders are persisted into the stored result (system-reminder
597
+ * extension) as text blocks of their own; the model must see them, the
598
+ * transcript view must not — Claude Code hides them the same way. A text block
599
+ * that ends in harness blocks keeps its own text and loses the tail.
600
+ */
601
+ export function stripReminderBlocks<T extends { content?: unknown }>(result: T): T {
602
+ const content = result?.content;
603
+ if (!Array.isArray(content)) return result;
604
+ let changed = false;
605
+ const kept: unknown[] = [];
606
+ for (const block of content) {
607
+ const text = block && typeof block === "object" && (block as any).type === "text" ? (block as any).text : undefined;
608
+ if (typeof text !== "string") {
609
+ kept.push(block);
610
+ continue;
611
+ }
612
+ const stripped = stripHarnessText(text);
613
+ if (stripped === text) {
614
+ kept.push(block);
615
+ continue;
616
+ }
617
+ changed = true;
618
+ if (stripped !== "") kept.push({ ...(block as object), text: stripped });
619
+ }
620
+ return changed ? { ...result, content: kept } : result;
621
+ }
622
+
397
623
  export function ccWrapBuiltinRenderers<TArgs = any>(
398
624
  label: string,
399
625
  base: {
@@ -455,7 +681,8 @@ export function ccWrapBuiltinRenderers<TArgs = any>(
455
681
  },
456
682
  };
457
683
  },
458
- renderResult(result: any, options: { expanded: boolean; isPartial: boolean }, theme: ThemeLike, context: any) {
684
+ renderResult(rawResult: any, options: { expanded: boolean; isPartial: boolean }, theme: ThemeLike, context: any) {
685
+ const result = stripReminderBlocks(rawResult);
459
686
  let inner: any;
460
687
  try {
461
688
  inner = base.renderResult?.(result, options, theme, { ...context, lastComponent: context.state.ccInnerResult });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cross-extension usage bus. pi's built-in footer only sums the MAIN session's
3
+ * entries (assistant messages, tool-result/summary usage) — it never sees the
4
+ * LLM calls our other extensions make out-of-band: in-process subagents run in
5
+ * their own sessions, and the auto-mode classifier and web-fetch reader are
6
+ * one-shot `completeSimple` calls with no session at all. Our footer wants a
7
+ * true all-in cost, so each of those call sites reports its usage here and the
8
+ * footer accumulates it on top of the main-session total.
9
+ *
10
+ * The channel carries a small pre-extracted record rather than a raw pi-ai
11
+ * `Usage`, so no consumer has to know the provider's shape. `recordUsage`
12
+ * swallows its own errors — accounting must never break a tool path.
13
+ */
14
+
15
+ export const USAGE_CHANNEL = "one-code:usage-recorded";
16
+
17
+ /**
18
+ * Session entry type each record is persisted under (`pi.appendEntry`), so the
19
+ * all-in cost survives `--continue`/`--session` and a footer that mounts late
20
+ * (the bus is live-only; the entries are the durable ledger the footer sums).
21
+ * Custom entries never enter the LLM context.
22
+ */
23
+ export const USAGE_ENTRY_TYPE = "one-code:usage";
24
+
25
+ export type UsageSource = "subagent" | "classifier" | "reader" | "recap" | "setup";
26
+
27
+ export interface UsageRecord {
28
+ source: UsageSource;
29
+ /** Dollar cost of the call (pi-ai `Usage.cost.total`), 0 when unpriced. */
30
+ cost: number;
31
+ }
32
+
33
+ /** Safe `cost.total` from an untrusted usage-like object. */
34
+ export function costOf(usage: unknown): number {
35
+ if (!usage || typeof usage !== "object") return 0;
36
+ const cost = (usage as { cost?: { total?: unknown } }).cost;
37
+ const total = cost?.total;
38
+ return typeof total === "number" && Number.isFinite(total) ? total : 0;
39
+ }
40
+
41
+ /**
42
+ * Minimal shape we need off the extension API: the event emitter, plus the
43
+ * entry appender when the caller has one (unit tests may pass a bare emitter).
44
+ */
45
+ type EventEmitter = {
46
+ events: { emit(channel: string, data: unknown): void };
47
+ appendEntry?<T>(customType: string, data?: T): void;
48
+ };
49
+
50
+ /**
51
+ * Report one non-main LLM call's usage: persisted as a session entry (the
52
+ * durable ledger) and announced on the bus (the live repaint signal). A no-op
53
+ * when the call was unpriced (the footer only sums cost), and never throws
54
+ * into the caller.
55
+ */
56
+ export function recordUsage(pi: EventEmitter, source: UsageSource, usage: unknown): void {
57
+ try {
58
+ const cost = costOf(usage);
59
+ if (cost === 0) return;
60
+ const record: UsageRecord = { source, cost };
61
+ pi.appendEntry?.(USAGE_ENTRY_TYPE, record);
62
+ pi.events.emit(USAGE_CHANNEL, record);
63
+ } catch {
64
+ // Accounting is best-effort; a broken emit must not fail the tool.
65
+ }
66
+ }
67
+
68
+ /** The persisted cost carried by a session entry, or 0 for any other entry. */
69
+ export function usageEntryCost(entry: unknown): number {
70
+ if (!entry || typeof entry !== "object") return 0;
71
+ const e = entry as { type?: string; customType?: string; data?: unknown };
72
+ if (e.type !== "custom" || e.customType !== USAGE_ENTRY_TYPE) return 0;
73
+ const cost = (e.data as { cost?: unknown } | undefined)?.cost;
74
+ return typeof cost === "number" && Number.isFinite(cost) ? cost : 0;
75
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Skill/command invocation counters (pure fs, no pi imports).
3
+ *
4
+ * `<plugin root>/usage.json`: `{version: 1, entries: {"<kind>:<key>":
5
+ * {count, lastUsedAt}}}`. Feeds the /plugins Installed tab's recency labels
6
+ * ("never used", "3× 120d") and its "Not used recently" grouping.
7
+ *
8
+ * Writes are a synchronous read-modify-write per invocation — Node is
9
+ * single-threaded and every fs call here is sync, so there is no interleaving
10
+ * window to debounce away; a timer-based debounce would only ADD an async gap
11
+ * (and a lost-write risk at process exit).
12
+ */
13
+
14
+ import { join } from "node:path";
15
+ import { readJsonFile, writeJsonAtomic } from "./atomic-write.ts";
16
+
17
+ const FILE = "usage.json";
18
+
19
+ export type UsageKind = "skill" | "command" | "mcpTool";
20
+
21
+ export interface UsageEntry {
22
+ count: number;
23
+ lastUsedAt: string;
24
+ }
25
+
26
+ export function usageKey(kind: UsageKind, key: string): string {
27
+ return `${kind}:${key}`;
28
+ }
29
+
30
+ export function readUsage(root: string): Record<string, UsageEntry> {
31
+ const parsed = readJsonFile<{ entries?: Record<string, unknown> }>(join(root, FILE));
32
+ if (!parsed?.entries || typeof parsed.entries !== "object") return {};
33
+ const entries: Record<string, UsageEntry> = {};
34
+ for (const [key, value] of Object.entries(parsed.entries)) {
35
+ const entry = value as { count?: unknown; lastUsedAt?: unknown };
36
+ if (typeof entry?.count === "number" && typeof entry?.lastUsedAt === "string") {
37
+ entries[key] = { count: entry.count, lastUsedAt: entry.lastUsedAt };
38
+ }
39
+ }
40
+ return entries;
41
+ }
42
+
43
+ export function recordUsage(root: string, kind: UsageKind, key: string, now: Date = new Date()): void {
44
+ // Best-effort bookkeeping: a write failure (disk full, unwritable root)
45
+ // must never take down the skill/command invocation being recorded.
46
+ try {
47
+ const path = join(root, FILE);
48
+ const existing = readJsonFile<{ version?: unknown; entries?: Record<string, unknown> }>(path) ?? {};
49
+ const entries = { ...(typeof existing.entries === "object" && existing.entries ? existing.entries : {}) };
50
+ const id = usageKey(kind, key);
51
+ const previous = entries[id] as { count?: unknown } | undefined;
52
+ const count = typeof previous?.count === "number" ? previous.count + 1 : 1;
53
+ entries[id] = { count, lastUsedAt: now.toISOString() };
54
+ writeJsonAtomic(path, { ...existing, version: 1, entries });
55
+ } catch {
56
+ // Usage stats are display-only; losing one tick is fine.
57
+ }
58
+ }
59
+
60
+ /** "never used" | "1× today" | "3× 120d" — the Installed tab's recency label. */
61
+ export function formatRecency(entry: UsageEntry | undefined, now: Date = new Date()): string {
62
+ if (!entry) return "never used";
63
+ const last = Date.parse(entry.lastUsedAt);
64
+ if (Number.isNaN(last)) return "never used";
65
+ const days = Math.max(0, Math.floor((now.getTime() - last) / 86_400_000));
66
+ return `${entry.count}× ${days === 0 ? "today" : `${days}d`}`;
67
+ }