gentle-pi 3.7.0 → 4.0.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 (316) hide show
  1. package/README.md +37 -6
  2. package/assets/agents/gentle-ai-explore.md +4 -4
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -9
  5. package/assets/orchestrator-delegation.md +40 -41
  6. package/assets/orchestrator-memory.md +1 -22
  7. package/assets/orchestrator-skills.md +1 -1
  8. package/assets/orchestrator.md +12 -24
  9. package/assets/support/strict-tdd-verify.md +4 -266
  10. package/assets/support/strict-tdd.md +8 -360
  11. package/bin/gentle-shell.mjs +254 -29
  12. package/docs/delegated-verification.md +26 -1
  13. package/docs/gentle-agents-activity.md +24 -0
  14. package/docs/gentle-shell.md +92 -28
  15. package/docs/native-authority-architecture.md +2 -2
  16. package/docs/prompt-history.md +280 -0
  17. package/docs/readme-reference.md +112 -206
  18. package/docs/telemetry.md +1 -1
  19. package/docs/yolo-mode.md +86 -0
  20. package/extensions/child-context.ts +26 -0
  21. package/extensions/child-safety.ts +23 -0
  22. package/extensions/gentle-agents.ts +429 -262
  23. package/extensions/gentle-ai.ts +781 -683
  24. package/extensions/gentle-shell.ts +1375 -88
  25. package/extensions/gentle-stats.ts +101 -0
  26. package/extensions/gentle-todo.ts +18 -7
  27. package/extensions/history/atomic-write.ts +38 -0
  28. package/extensions/history/hide-prompts.ts +183 -0
  29. package/extensions/history/index.ts +1419 -0
  30. package/extensions/history/load-shared-history.ts +39 -0
  31. package/extensions/history/selector-helpers.ts +538 -0
  32. package/extensions/history/session-scan.ts +233 -0
  33. package/extensions/history/store.ts +1119 -0
  34. package/extensions/nan-provider.ts +6 -0
  35. package/extensions/quiet-tools.ts +179 -87
  36. package/extensions/resume-hint.ts +60 -0
  37. package/extensions/skill-registry.ts +16 -12
  38. package/extensions/startup-banner.ts +60 -31
  39. package/lib/agent-assets.ts +604 -0
  40. package/lib/agent-profile-pin.ts +12 -0
  41. package/lib/agents-message-delivery.ts +181 -0
  42. package/lib/agents-protocol.ts +60 -0
  43. package/lib/agents-runner.ts +96 -98
  44. package/lib/agents-view.ts +26 -3
  45. package/lib/agents-widget.ts +16 -9
  46. package/lib/append-system-prompt.ts +21 -0
  47. package/lib/bounded-writer-admission.ts +147 -0
  48. package/lib/card-style-policy.ts +60 -0
  49. package/lib/child-context-files.ts +166 -0
  50. package/lib/codemode-renderer.ts +185 -0
  51. package/lib/command-palette-catalog.ts +3 -9
  52. package/lib/command-palette.ts +25 -14
  53. package/lib/destructive-command-guard.ts +144 -0
  54. package/lib/gentle-ai-elapsed-store.ts +87 -0
  55. package/lib/gentle-ai-renderer.ts +196 -39
  56. package/lib/gentle-shell-launcher.ts +24 -13
  57. package/lib/gentle-shell-resume-hint.ts +176 -0
  58. package/lib/history-capture-policy.ts +95 -0
  59. package/lib/model-routing-authority.ts +5 -1
  60. package/lib/nan-provider.ts +227 -0
  61. package/lib/native-review-cli.ts +49 -108
  62. package/lib/odd-phase-inference.ts +231 -0
  63. package/lib/odd-phase.ts +141 -0
  64. package/lib/overlay-repaint.ts +26 -0
  65. package/lib/pi-tui-keys.ts +53 -0
  66. package/lib/review-candidate-view-owner.ts +67 -17
  67. package/lib/review-candidate-view.ts +112 -25
  68. package/lib/review-reminder-receipt.ts +48 -8
  69. package/lib/review-risk-assessment.ts +156 -11
  70. package/lib/review-sidebar-state.ts +223 -0
  71. package/lib/selection-engine.ts +515 -0
  72. package/lib/session-messaging-grants.ts +135 -0
  73. package/lib/session-worktree-registry.ts +14 -2
  74. package/lib/shell-bar.ts +179 -24
  75. package/lib/shell-card.ts +287 -18
  76. package/lib/shell-changes-view.ts +2 -1
  77. package/lib/shell-prompt.ts +102 -6
  78. package/lib/shell-sidebar-layout.ts +78 -14
  79. package/lib/shell-sidebar.ts +15 -1
  80. package/lib/shell-todo.ts +23 -13
  81. package/lib/shell-usage-view.ts +9 -4
  82. package/lib/shell-usage.ts +66 -10
  83. package/lib/stats-collector.ts +381 -0
  84. package/lib/stats-view.ts +431 -0
  85. package/lib/theme-customization.ts +52 -0
  86. package/lib/vim-editor-adapter.ts +379 -0
  87. package/lib/vim-normal-engine.ts +154 -0
  88. package/lib/vim-operator-engine.ts +416 -0
  89. package/lib/vim-policy.ts +49 -0
  90. package/lib/vim-visual-engine.ts +107 -0
  91. package/lib/visual-customization-policy.ts +108 -0
  92. package/lib/visual-customize-view.ts +330 -0
  93. package/lib/visual-profiles.ts +228 -0
  94. package/lib/yolo-session-policy.ts +240 -0
  95. package/package.json +20 -8
  96. package/runtime/gentle-shell-launcher.mjs +23 -12
  97. package/runtime/gentle-shell-resume-hint.mjs +177 -0
  98. package/runtime/native-review-cli.mjs +49 -108
  99. package/runtime/review-risk-assessment.mjs +154 -9
  100. package/scripts/build-runtime-modules.mjs +1 -0
  101. package/scripts/gentle-ai-installer.mjs +14 -13
  102. package/scripts/mirror-odd-routing.mjs +2 -2
  103. package/scripts/run-test-suite.mjs +76 -0
  104. package/scripts/test-packed-runner.mjs +31 -14
  105. package/scripts/verify-package-files.mjs +11 -22
  106. package/skills/branch-pr/SKILL.md +24 -52
  107. package/skills/chained-pr/SKILL.md +31 -15
  108. package/skills/chained-pr/references/chaining-details.md +31 -20
  109. package/skills/gentle-ai/SKILL.md +9 -15
  110. package/skills/issue-creation/SKILL.md +8 -2
  111. package/skills/issue-creation/references/delegated-workflow-actions.md +19 -0
  112. package/skills/work-unit-commits/SKILL.md +4 -3
  113. package/tests/agent-profiles.test.ts +18 -0
  114. package/tests/agents-fake-child.ts +2 -2
  115. package/tests/agents-message-delivery.test.ts +106 -0
  116. package/tests/agents-protocol.test.ts +40 -0
  117. package/tests/agents-runner.test.ts +378 -89
  118. package/tests/agents-view-thread-identity.test.ts +169 -0
  119. package/tests/agents-view.test.ts +8 -2
  120. package/tests/agents-widget.test.ts +154 -15
  121. package/tests/append-system-prompt-route.test.ts +160 -0
  122. package/tests/append-system-prompt.test.ts +46 -0
  123. package/tests/artifact-language.test.ts +19 -213
  124. package/tests/ask-user-question.test.ts +44 -1
  125. package/tests/asset-installation-runtime.test.ts +5 -16
  126. package/tests/autonomous-guard.test.ts +69 -1
  127. package/tests/bounded-writer-admission.test.ts +95 -0
  128. package/tests/branch-pr-skill.test.ts +43 -0
  129. package/tests/card-style-policy.test.ts +55 -0
  130. package/tests/chained-pr-skill.test.ts +124 -0
  131. package/tests/child-context-files.test.ts +255 -0
  132. package/tests/child-safety.test.ts +82 -0
  133. package/tests/codemode-rendering.test.ts +491 -0
  134. package/tests/command-palette.test.ts +39 -3
  135. package/tests/delegated-key-learnings-contract.test.ts +0 -76
  136. package/tests/destructive-command-guard.test.ts +84 -0
  137. package/tests/devbinary/native-review-parity.devtest.ts +170 -2
  138. package/tests/devbinary/non-git-subagent-bootstrap.devtest.ts +193 -0
  139. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +7 -0
  140. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +3 -0
  141. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T08-00-00-000Z_ddd.jsonl +2 -0
  142. package/tests/fixtures/stats/sessions/--work-alpha--/2026-09-30T09-00-00-000Z_eee.jsonl +3 -0
  143. package/tests/fixtures/stats/sessions/--work-alpha--/run-1/session.jsonl +2 -0
  144. package/tests/fixtures/stats/sessions/--work-beta--/2026-09-01T12-00-00-000Z_ccc.jsonl +2 -0
  145. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-28T10-00-00-000Z_aaa.jsonl +2 -0
  146. package/tests/fixtures/stats/user-pi/sessions/--work-alpha--/2026-09-29T23-00-00-000Z_bbb.jsonl +4 -0
  147. package/tests/fixtures/stats/user-pi/sessions/--work-gamma--/2026-09-20T09-00-00-000Z_fff.jsonl +3 -0
  148. package/tests/generic-agent-tools.test.ts +54 -0
  149. package/tests/gentle-agents.test.ts +1047 -252
  150. package/tests/gentle-ai-binary.test.ts +3 -3
  151. package/tests/gentle-ai-elapsed-store.test.ts +68 -0
  152. package/tests/gentle-ai-installer.test.ts +68 -54
  153. package/tests/gentle-ai-renderer.test.ts +487 -8
  154. package/tests/gentle-ai.test.ts +288 -65
  155. package/tests/gentle-card-text.ts +2 -1
  156. package/tests/gentle-shell-bin.test.ts +651 -114
  157. package/tests/gentle-shell-launcher.test.ts +99 -52
  158. package/tests/gentle-shell-resume-hint.test.ts +270 -0
  159. package/tests/gentle-shell.test.ts +3839 -187
  160. package/tests/gentle-stats.test.ts +152 -0
  161. package/tests/gentle-theme.test.ts +4 -1
  162. package/tests/gentle-todo.test.ts +80 -8
  163. package/tests/history-atomic-write.test.ts +57 -0
  164. package/tests/history-capture-policy.test.ts +102 -0
  165. package/tests/history-command-registration.test.ts +164 -0
  166. package/tests/history-dedupe-entries.test.ts +123 -0
  167. package/tests/history-delete-backfill.test.ts +190 -0
  168. package/tests/history-delete-confirm.test.ts +460 -0
  169. package/tests/history-dispatch.test.ts +180 -0
  170. package/tests/history-drain-hidden.test.ts +110 -0
  171. package/tests/history-drain-order.test.ts +98 -0
  172. package/tests/history-expanded-globals.test.ts +62 -0
  173. package/tests/history-gc.test.ts +832 -0
  174. package/tests/history-header-layout.test.ts +265 -0
  175. package/tests/history-hide-prompts.test.ts +275 -0
  176. package/tests/history-lazy-windowing.test.ts +508 -0
  177. package/tests/history-legacy-migrate-v2.test.ts +297 -0
  178. package/tests/history-load-shared-history.test.ts +53 -0
  179. package/tests/history-max-results-cap.test.ts +76 -0
  180. package/tests/history-multi-reader.test.ts +203 -0
  181. package/tests/history-off-path.test.ts +170 -0
  182. package/tests/history-openflow-integration.test.ts +173 -0
  183. package/tests/history-overlay-margin.test.ts +326 -0
  184. package/tests/history-preview-layout.test.ts +93 -0
  185. package/tests/history-registry.test.ts +143 -0
  186. package/tests/history-scope-delete.test.ts +411 -0
  187. package/tests/history-search-caret-keys.test.ts +142 -0
  188. package/tests/history-seed-bootstrap.test.ts +170 -0
  189. package/tests/history-seed-regen.test.ts +129 -0
  190. package/tests/history-selector-windowing.test.ts +94 -0
  191. package/tests/history-session-scan-directory.test.ts +87 -0
  192. package/tests/history-session-scan-extract.test.ts +583 -0
  193. package/tests/history-session-writer.test.ts +351 -0
  194. package/tests/history-store-paths.test.ts +79 -0
  195. package/tests/history-tombstone-exact.test.ts +139 -0
  196. package/tests/history-wheel-mouse.test.ts +242 -0
  197. package/tests/inprocess-reviewer.test.ts +29 -19
  198. package/tests/issue-creation-skill.test.ts +61 -0
  199. package/tests/model-routing-authority.test.ts +16 -0
  200. package/tests/nan-provider.test.ts +471 -0
  201. package/tests/native-review-capability-contract.test.ts +7 -1
  202. package/tests/native-review-cli.test.ts +6 -120
  203. package/tests/native-review-parity-runtime.test.ts +100 -3
  204. package/tests/odd-integration.test.ts +67 -0
  205. package/tests/odd-phase-inference.test.ts +213 -0
  206. package/tests/odd-phase-loader.test.ts +253 -0
  207. package/tests/odd-phase.test.ts +307 -0
  208. package/tests/odd-routing-canonical-ratchet.test.ts +11 -6
  209. package/tests/odd-routing-contract.test.ts +86 -35
  210. package/tests/orchestrator-budget.test.ts +14 -39
  211. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  212. package/tests/overlay-repaint.test.ts +74 -0
  213. package/tests/package-manifest.test.ts +252 -115
  214. package/tests/packed-runner-owned-path.test.ts +46 -0
  215. package/tests/persona-single-channel.test.ts +6 -6
  216. package/tests/provider-defect-handoff.test.ts +3 -11
  217. package/tests/quiet-bash-runtime.test.ts +76 -0
  218. package/tests/quiet-tool-rendering.test.ts +409 -184
  219. package/tests/rdd-aware-verification-contract.test.ts +76 -1
  220. package/tests/rdd-status-line.test.ts +9 -4
  221. package/tests/resume-hint-extension.test.ts +122 -0
  222. package/tests/review-agent-end-preflight.test.ts +176 -12
  223. package/tests/review-candidate-owner-retry.test.ts +22 -1
  224. package/tests/review-candidate-view.test.ts +298 -0
  225. package/tests/review-contract-prompt.test.ts +108 -43
  226. package/tests/review-controller-lock-status.test.ts +0 -1
  227. package/tests/review-controller-native-routing.test.ts +611 -5
  228. package/tests/review-controller-workspace-root.test.ts +163 -4
  229. package/tests/review-host-relay-routing.test.ts +338 -2
  230. package/tests/review-integration-v2-forward.test.ts +200 -0
  231. package/tests/review-ledger-contract.test.ts +10 -34
  232. package/tests/review-reminder-receipt.test.ts +47 -1
  233. package/tests/review-risk-assessment.test.ts +498 -6
  234. package/tests/review-sidebar-state.test.ts +402 -0
  235. package/tests/run-test-suite.test.ts +124 -0
  236. package/tests/runtime-harness.mjs +145 -786
  237. package/tests/runtime-metrics-children.test.ts +16 -23
  238. package/tests/selection-engine.test.ts +421 -0
  239. package/tests/session-messaging-grants.test.ts +255 -0
  240. package/tests/session-worktree-registry.test.ts +77 -0
  241. package/tests/shell-bar.test.ts +382 -1
  242. package/tests/shell-card.test.ts +353 -1
  243. package/tests/shell-changes-view.test.ts +52 -0
  244. package/tests/shell-prompt.test.ts +94 -2
  245. package/tests/shell-sidebar-layout.test.ts +325 -21
  246. package/tests/shell-sidebar-scroll-benchmark.test.ts +255 -0
  247. package/tests/shell-todo.test.ts +87 -1
  248. package/tests/shell-usage-view.test.ts +27 -0
  249. package/tests/shell-usage.test.ts +73 -0
  250. package/tests/skill-registry.test.ts +50 -1
  251. package/tests/startup-banner.test.ts +130 -2
  252. package/tests/stats-collector.test.ts +195 -0
  253. package/tests/stats-view.test.ts +202 -0
  254. package/tests/telemetry-trigger.test.ts +81 -20
  255. package/tests/theme-customization.test.ts +72 -0
  256. package/tests/vim-editor-adapter-host-resolution.test.ts +37 -0
  257. package/tests/vim-editor-adapter.test.ts +804 -0
  258. package/tests/vim-normal-engine.test.ts +101 -0
  259. package/tests/vim-operator-engine.test.ts +215 -0
  260. package/tests/vim-policy.test.ts +19 -0
  261. package/tests/vim-visual-engine.test.ts +52 -0
  262. package/tests/visual-customization-policy.test.ts +110 -0
  263. package/tests/visual-customize-view.test.ts +418 -0
  264. package/tests/visual-profiles.test.ts +87 -0
  265. package/tests/yolo-customize.test.ts +256 -0
  266. package/tests/yolo-mode-runtime.test.ts +161 -0
  267. package/tests/yolo-mode.test.ts +261 -0
  268. package/tests/yolo-session-policy.test.ts +59 -0
  269. package/themes/Gentle.json +2 -1
  270. package/themes/Gentleman-Cute.json +2 -1
  271. package/themes/Gentleman-Sexy.json +2 -1
  272. package/assets/agents/sdd-apply.md +0 -159
  273. package/assets/agents/sdd-archive.md +0 -228
  274. package/assets/agents/sdd-design.md +0 -49
  275. package/assets/agents/sdd-explore.md +0 -48
  276. package/assets/agents/sdd-init.md +0 -56
  277. package/assets/agents/sdd-onboard.md +0 -52
  278. package/assets/agents/sdd-proposal.md +0 -64
  279. package/assets/agents/sdd-remediate.md +0 -37
  280. package/assets/agents/sdd-research.md +0 -49
  281. package/assets/agents/sdd-spec.md +0 -192
  282. package/assets/agents/sdd-status.md +0 -54
  283. package/assets/agents/sdd-tasks.md +0 -108
  284. package/assets/agents/sdd-verify.md +0 -124
  285. package/assets/chains/sdd-full.chain.md +0 -83
  286. package/assets/chains/sdd-plan.chain.md +0 -56
  287. package/assets/chains/sdd-verify.chain.md +0 -43
  288. package/assets/sdd-orchestrator-workflow.md +0 -319
  289. package/assets/support/sdd-status-contract.md +0 -77
  290. package/docs/assets/diagrams/sdd-cycle.svg +0 -14
  291. package/extensions/sdd-init.ts +0 -816
  292. package/lib/openspec-deltas.ts +0 -156
  293. package/lib/sdd-preflight.ts +0 -1066
  294. package/lib/sdd-research-capabilities.ts +0 -94
  295. package/lib/sdd-status.ts +0 -26
  296. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +0 -54
  297. package/tests/fixtures/native-review-cli/v2.1.3/bind-sdd.json +0 -25
  298. package/tests/fixtures/v0.10.7/assets/agents/sdd-apply.md +0 -132
  299. package/tests/openspec-deltas.test.ts +0 -209
  300. package/tests/sdd-agent-tools.test.ts +0 -156
  301. package/tests/sdd-archive-replay.test.ts +0 -82
  302. package/tests/sdd-classical-continuation.test.ts +0 -74
  303. package/tests/sdd-execution-routing-contract.test.ts +0 -44
  304. package/tests/sdd-managed-runtime-settlement.test.ts +0 -155
  305. package/tests/sdd-native-managed-uptake.test.ts +0 -243
  306. package/tests/sdd-no-attempts-contract.test.ts +0 -15
  307. package/tests/sdd-odd-integration.test.ts +0 -33
  308. package/tests/sdd-optional-research.test.ts +0 -124
  309. package/tests/sdd-planning-routing-contract.test.ts +0 -45
  310. package/tests/sdd-preflight-rpc-input.test.ts +0 -125
  311. package/tests/sdd-preflight.test.ts +0 -541
  312. package/tests/sdd-research-capabilities.test.ts +0 -114
  313. package/tests/sdd-research-live.test.ts +0 -241
  314. package/tests/sdd-selection-transport.test.ts +0 -653
  315. package/tests/sdd-status.test.ts +0 -9
  316. package/tests/sdd-task-truth.test.ts +0 -43
package/lib/shell-card.ts CHANGED
@@ -23,12 +23,45 @@ export interface Card {
23
23
 
24
24
  export interface CardTheme {
25
25
  fg(color: string, text: string): string;
26
+ /** Pi themes have it; the float style needs it to paint its panel. */
27
+ bg?(color: string, text: string): string;
28
+ }
29
+
30
+ // Conversation cards come in two styles, chosen in /gentle:customize. Pi loads
31
+ // every extension with its own jiti loader (moduleCache:false), so each one
32
+ // gets a copy of this module; the global symbol keeps one style per process.
33
+ export const CARD_STYLE = {
34
+ NEON: "neon",
35
+ FLOAT: "float",
36
+ } as const;
37
+
38
+ export type CardStyle = (typeof CARD_STYLE)[keyof typeof CARD_STYLE];
39
+
40
+ const CARD_STYLE_SLOT = Symbol.for("gentle-pi.card-style");
41
+ const styleState = globalThis as typeof globalThis & { [CARD_STYLE_SLOT]?: unknown };
42
+
43
+ export function cardStyle(): CardStyle {
44
+ return styleState[CARD_STYLE_SLOT] === CARD_STYLE.NEON ? CARD_STYLE.NEON : CARD_STYLE.FLOAT;
45
+ }
46
+
47
+ export function setCardStyle(style: CardStyle): void {
48
+ styleState[CARD_STYLE_SLOT] = style;
26
49
  }
27
50
 
28
51
  export interface CardRenderOptions {
29
52
  expanded: boolean;
53
+ /** Optional physical-row budget for useful tool previews; notices default to one. */
54
+ previewRows?: number;
30
55
  /** Right-aligned hint in the top rule, e.g. the expand key. May carry ANSI. */
31
56
  hint?: string;
57
+ /**
58
+ * Fixed chrome panels (Agents, Todos, Status rail) opt in here. The float
59
+ * style then paints them like float cards, centered between two padding
60
+ * rows with a blank row between header and body: `panelExtraRows` taller
61
+ * than neon when a body exists, with the header on `panelHeaderRow`.
62
+ * Neon keeps the outlined frame. Conversation cards never set it.
63
+ */
64
+ panel?: boolean;
32
65
  }
33
66
 
34
67
  export const CARD_GLYPH = "✿";
@@ -54,8 +87,39 @@ const BODY_ROLE = "text";
54
87
  const SEPARATOR = "·";
55
88
  const FRAME_COLUMNS = 4;
56
89
 
90
+ // The characters that draw a card around its content. The float style keeps
91
+ // every row the same width: the left edge becomes an accent bar and the rules
92
+ // and the right rail become spaces. Rows are built from these by position, so
93
+ // content that happens to contain box-drawing characters is never rewritten.
94
+ interface CardChrome {
95
+ topLeft: string;
96
+ topLead: string;
97
+ topRight: string;
98
+ rule: string;
99
+ side: string;
100
+ rail: string;
101
+ bottomLeft: string;
102
+ bottomRight: string;
103
+ }
104
+
105
+ const OUTLINE_CHROME: CardChrome = { topLeft: "╭", topLead: "─ ", topRight: "╮", rule: "─", side: "│", rail: "│", bottomLeft: "╰", bottomRight: "╯" };
106
+ // `▎ ` is one cell narrower than `╭─ `: the spare cell moves to the right end
107
+ // so the heading glyph starts in the same column as the body text.
108
+ const FLOAT_CHROME: CardChrome = { topLeft: "▎", topLead: " ", topRight: " ", rule: " ", side: "▎", rail: " ", bottomLeft: "▎", bottomRight: " " };
109
+ let chrome = OUTLINE_CHROME;
110
+
111
+ function withChrome<T>(next: CardChrome, run: () => T): T {
112
+ const previous = chrome;
113
+ chrome = next;
114
+ try {
115
+ return run();
116
+ } finally {
117
+ chrome = previous;
118
+ }
119
+ }
120
+
57
121
  function rule(length: number): string {
58
- return "─".repeat(Math.max(0, length));
122
+ return chrome.rule.repeat(Math.max(0, length));
59
123
  }
60
124
 
61
125
  function titleText(card: Card, theme: CardTheme): { styled: string; width: number } {
@@ -92,13 +156,13 @@ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: stri
92
156
  const styledTitleWidth = title.width <= titleWidth ? title.width : visibleWidth(styledTitle);
93
157
  const fill = rule(targetWidth - styledTitleWidth - 5 - hintWidth);
94
158
  const tail = shownHint ? ` ${theme.fg(HINT_ROLE, shownHint)} ` : "";
95
- return theme.fg(FRAME_ROLE[card.tone], "╭") + frame(theme, card.tone, "─ ") + styledTitle + frame(theme, card.tone, ` ${fill}`) + tail + frame(theme, card.tone, "╮");
159
+ return theme.fg(FRAME_ROLE[card.tone], chrome.topLeft) + frame(theme, card.tone, chrome.topLead) + styledTitle + frame(theme, card.tone, ` ${fill}`) + tail + frame(theme, card.tone, chrome.topRight);
96
160
  }
97
161
 
98
162
  export function cardLine(text: string, tone: CardTone, theme: CardTheme, width: number): string {
99
163
  const targetWidth = Math.max(0, Math.floor(width));
100
164
  if (targetWidth === 0) return "";
101
- const left = theme.fg(FRAME_ROLE[tone], "│");
165
+ const left = theme.fg(FRAME_ROLE[tone], chrome.side);
102
166
  if (targetWidth === 1) return left;
103
167
  if (targetWidth === 2) return left + frame(theme, tone, "│");
104
168
  if (targetWidth === 3) return `${left} ${frame(theme, tone, "│")}`;
@@ -106,34 +170,239 @@ export function cardLine(text: string, tone: CardTone, theme: CardTheme, width:
106
170
  const innerWidth = targetWidth - FRAME_COLUMNS;
107
171
  const clipped = innerWidth === 0 ? "" : truncateToWidth(text, innerWidth, "…");
108
172
  const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(clipped)));
109
- return `${left} ${clipped}${padding} ${frame(theme, tone, "│")}`;
173
+ return `${left} ${clipped}${padding} ${frame(theme, tone, chrome.rail)}`;
110
174
  }
111
175
 
112
- export function cardBottom(tone: CardTone, theme: CardTheme, width: number): string {
176
+ export function cardBottom(tone: CardTone, theme: CardTheme, width: number, content?: string): string {
113
177
  const targetWidth = Math.max(0, Math.floor(width));
114
178
  if (targetWidth === 0) return "";
115
- const left = theme.fg(FRAME_ROLE[tone], "╰");
179
+ const left = theme.fg(FRAME_ROLE[tone], chrome.bottomLeft);
116
180
  if (targetWidth === 1) return left;
117
- return left + frame(theme, tone, `${rule(targetWidth - 2)}╯`);
181
+ if (content === undefined || visibleWidth(content) === 0) return left + frame(theme, tone, `${rule(targetWidth - 2)}${chrome.bottomRight}`);
182
+ const contentWidth = visibleWidth(content) + 2;
183
+ // Responsive: the duration rides the closing rule only when it fits with a minimum fill.
184
+ if (targetWidth - 2 - contentWidth < 3) return left + frame(theme, tone, `${rule(targetWidth - 2)}${chrome.bottomRight}`);
185
+ return left + frame(theme, tone, `${rule(targetWidth - 2 - contentWidth)} `) + theme.fg(HINT_ROLE, content) + frame(theme, tone, ` ${chrome.bottomRight}`);
118
186
  }
119
187
 
120
188
  export function cardInnerWidth(width: number): number {
121
189
  return Math.max(1, width - FRAME_COLUMNS);
122
190
  }
123
191
 
192
+ /** Shared body for whole cards and separate call/result components. Budgets apply after wrapping. */
193
+ export function cardBodyRows(
194
+ rows: readonly string[], tone: CardTone, theme: CardTheme, width: number,
195
+ options: CardRenderOptions,
196
+ ): string[] {
197
+ if (width <= 0) return [];
198
+ const wrapped = rows.flatMap((row) => row.split("\n").flatMap((line) => line === "" ? [""] : wrapTextWithAnsi(line, cardInnerWidth(width))));
199
+ const limit = Math.max(0, Math.floor(options.previewRows ?? 3));
200
+ const shown = options.expanded ? wrapped : wrapped.slice(0, limit);
201
+ return shown.map((line) => cardLine(line, tone, theme, width));
202
+ }
203
+
204
+ // Pi draws a tool row as the call component followed, once a result exists,
205
+ // by the result component, which closes the frame. The call cannot see the
206
+ // result, so result renderers mark the row state both share, and a call still
207
+ // waiting for its first result closes its own frame.
208
+ const RESULT_MARK = Symbol.for("gentle-pi.card-result");
209
+ const RUNNING_TEXT = "running…";
210
+ const RUNNING_ROLE = "muted";
211
+
212
+ /** The fields of pi's tool render context that tell whether a result exists. */
213
+ export interface CardRowContext {
214
+ isPartial?: boolean;
215
+ state?: unknown;
216
+ }
217
+
218
+ /** Result renderers mark the shared row state: from now on a result component closes the card. */
219
+ export function markCardResult(state: unknown): void {
220
+ if (state !== null && typeof state === "object") (state as Record<symbol, unknown>)[RESULT_MARK] = true;
221
+ }
222
+
223
+ /** Whether a call card must close its own frame. Read it at render time: pi builds the result component after the call. */
224
+ export function cardAwaitingResult(context: CardRowContext): boolean {
225
+ if (context.isPartial === false) return false;
226
+ const state = context.state;
227
+ return !(state !== null && typeof state === "object" && (state as Record<symbol, unknown>)[RESULT_MARK] === true);
228
+ }
229
+
230
+ /** The muted body row of a call card that is still waiting for its result. */
231
+ export function cardRunningLine(tone: CardTone, theme: CardTheme, width: number): string {
232
+ return cardLine(theme.fg(RUNNING_ROLE, RUNNING_TEXT), tone, theme, width);
233
+ }
234
+
235
+ /** Quiet calls may continue long or multiline headings inside the same frame. */
236
+ export function cardTopRows(card: Card, theme: CardTheme, width: number, hint?: string): string[] {
237
+ const target = Math.max(0, Math.floor(width));
238
+ if (target === 0) return [];
239
+ if (target < 8) return [cardTop(card, theme, target, hint)];
240
+ const [first = "", ...rest] = card.title.split("\n");
241
+ // Reserve the hint before wrapping, plus one rule column and enough heading
242
+ // space to retain identity. This policy belongs only to continuing tool tops.
243
+ const available = target - 6 - visibleWidth(`${card.glyph ?? CARD_GLYPH} `) - (card.subtitle ? visibleWidth(card.subtitle) + 3 : 0);
244
+ const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
245
+ const shownHint = hint && available - hintWidth >= 12 ? hint : undefined;
246
+ const room = Math.max(1, available - (shownHint ? hintWidth : 0));
247
+ const [head = "", ...overflow] = wrapTextWithAnsi(first, room);
248
+ return [
249
+ cardTop({ ...card, title: head }, theme, target, shownHint),
250
+ ...cardBodyRows([...overflow, ...rest], card.tone, theme, target, { expanded: true }),
251
+ ];
252
+ }
253
+
254
+ /** The rows one card component draws, by position. */
255
+ export interface CardParts {
256
+ /** The top rule plus any heading continuation rows. */
257
+ head?: readonly string[];
258
+ body?: readonly string[];
259
+ /** The closing rule, when this component closes the card. */
260
+ bottom?: string;
261
+ /** A component above this one already drew the heading. */
262
+ afterHeading?: boolean;
263
+ }
264
+
265
+ const FLOAT_MARGIN = 1;
266
+ const FLOAT_MIN_WIDTH = 10;
267
+ const FLOAT_BG_ROLE: Record<CardTone, string> = {
268
+ [CARD_TONE.INFO]: "toolSuccessBg",
269
+ [CARD_TONE.SUCCESS]: "toolSuccessBg",
270
+ [CARD_TONE.WARNING]: "toolPendingBg",
271
+ [CARD_TONE.ERROR]: "toolErrorBg",
272
+ };
273
+ const BG_RESET = "\x1b[49m";
274
+ // Full resets (pi-tui truncation inserts one) and background resets.
275
+ const BG_CLEARING = /\x1b\[(?:0|49)?m/g;
276
+
277
+ function panelOpener(theme: CardTheme, tone: CardTone): string {
278
+ if (typeof theme.bg !== "function") return "";
279
+ try {
280
+ const painted = theme.bg(FLOAT_BG_ROLE[tone], "");
281
+ return painted.endsWith(BG_RESET) ? painted.slice(0, -BG_RESET.length) : "";
282
+ } catch {
283
+ // A theme without the tool background keeps the outlined card.
284
+ return "";
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Draws one conversation card component in the active style. The outlined
290
+ * style returns the parts unchanged; the float style renders them one margin
291
+ * narrower with the float chrome and paints a tone background behind every
292
+ * row. A blank row sits above the heading and between it and the body.
293
+ */
294
+ export function floatRows(tone: CardTone, theme: CardTheme, width: number, render: (width: number) => CardParts): string[] {
295
+ const target = Math.max(0, Math.floor(width));
296
+ const open = floatOpener(theme, tone, target);
297
+ if (!open) {
298
+ const { head = [], body = [], bottom } = render(width);
299
+ return [...head, ...body, ...(bottom === undefined ? [] : [bottom])];
300
+ }
301
+ const inner = target - FLOAT_MARGIN * 2;
302
+ const rows = withChrome(FLOAT_CHROME, () => {
303
+ const { head = [], body = [], bottom, afterHeading = false } = render(inner);
304
+ const blank = cardBottom(tone, theme, inner);
305
+ return [
306
+ ...(head.length > 0 ? [blank, ...head] : []),
307
+ ...(body.length > 0 && (head.length > 0 || afterHeading) ? [blank] : []),
308
+ ...body,
309
+ ...(bottom === undefined ? [] : [bottom]),
310
+ ];
311
+ });
312
+ return paintFloat(rows, open);
313
+ }
314
+
315
+ /** The tone background opener when the float style applies at this width, or "" for the outlined card. */
316
+ function floatOpener(theme: CardTheme, tone: CardTone, width: number): string {
317
+ return cardStyle() === CARD_STYLE.FLOAT && Math.floor(width) >= FLOAT_MIN_WIDTH ? panelOpener(theme, tone) : "";
318
+ }
319
+
320
+ // Paints every row behind its tone background, re-armed after any reset the
321
+ // content carries, inside a transparent one-column margin on both sides.
322
+ // Padding rows keep their tone-coloured accent just like content rows.
323
+ function paintFloat(rows: readonly string[], open: string): string[] {
324
+ const margin = " ".repeat(FLOAT_MARGIN);
325
+ return rows.map((row) => `${margin}${open}${row.replace(BG_CLEARING, (reset) => reset + open)}${BG_RESET}${margin}`);
326
+ }
327
+
328
+ /**
329
+ * Content columns of a panel body (Agents, Todos, Status rail) in the active
330
+ * style. Callers that pre-wrap or pre-fit rows use it so a float panel, two
331
+ * columns narrower than the outlined frame, never re-wraps or clips them.
332
+ */
333
+ export function panelInnerWidth(theme: CardTheme, width: number, tone: CardTone = CARD_TONE.INFO): number {
334
+ return floatOpener(theme, tone, width) ? cardInnerWidth(Math.floor(width) - FLOAT_MARGIN * 2) : cardInnerWidth(width);
335
+ }
336
+
337
+ /**
338
+ * The row a panel draws its header on in the active style: 0 for the
339
+ * outlined frame, 1 below the float panel's top padding row. Callers
340
+ * hit-test their header control with it.
341
+ */
342
+ export function panelHeaderRow(theme: CardTheme, width: number, tone: CardTone = CARD_TONE.INFO): number {
343
+ return floatOpener(theme, tone, width) ? 1 : 0;
344
+ }
345
+
346
+ /**
347
+ * Rows a float panel with a body adds over the outlined frame: the top
348
+ * padding row and the separator below the header, or 0 for the outlined
349
+ * frame. Callers with a row budget spend them from the body.
350
+ */
351
+ export function panelExtraRows(theme: CardTheme, width: number, tone: CardTone = CARD_TONE.INFO): number {
352
+ return floatOpener(theme, tone, width) ? 2 : 0;
353
+ }
354
+
124
355
  export function renderCard(card: Card, theme: CardTheme, width: number, options: CardRenderOptions): string[] {
125
- const innerWidth = Math.max(1, width - FRAME_COLUMNS);
356
+ // Only opt-in tool previews collapse to nothing at nonpositive widths; the
357
+ // legacy path keeps its empty-row shape for widgets that call it unguarded.
358
+ if (options.previewRows !== undefined) return width <= 0 ? [] : floatRows(card.tone, theme, width, (inner) => ({
359
+ head: [cardTop(card, theme, inner, options.hint)],
360
+ body: cardBodyRows(card.body.map((line) => theme.fg(BODY_ROLE, line)), card.tone, theme, inner, options),
361
+ bottom: cardBottom(card.tone, theme, inner),
362
+ }));
363
+ // Without the panel opt-in, legacy cards always keep the outlined frame.
364
+ if (chrome !== OUTLINE_CHROME) return withChrome(OUTLINE_CHROME, () => renderCard(card, theme, width, options));
365
+ const open = options.panel ? floatOpener(theme, card.tone, width) : "";
366
+ if (open) return paintFloat(withChrome(FLOAT_CHROME, () => floatPanel(card, theme, Math.floor(width) - FLOAT_MARGIN * 2, options)), open);
126
367
  const top = cardTop(card, theme, width, options.hint);
127
368
  const bottom = cardBottom(card.tone, theme, width);
369
+ return [top, ...cardText(card, theme, cardInnerWidth(width), options.expanded).map((line) => cardLine(line, card.tone, theme, width)), bottom];
370
+ }
371
+
372
+ /** Body text of a legacy card or panel, wrapped to its content columns, before the side rails. */
373
+ function cardText(card: Card, theme: CardTheme, innerWidth: number, expanded: boolean): string[] {
128
374
  const lines = bodyLines(card, innerWidth);
129
- const body = (() => {
130
- if (lines.length === 0) return [];
131
- if (!options.expanded) {
132
- const first = lines.find((line) => line !== "") ?? "";
133
- const clipped = lines.length > 1 ? truncateToWidth(first, Math.max(1, innerWidth - 1), "") + "…" : first;
134
- return [cardLine(theme.fg(BODY_ROLE, clipped), card.tone, theme, width)];
135
- }
136
- return lines.map((line) => cardLine(line === "" ? "" : theme.fg(BODY_ROLE, line), card.tone, theme, width));
137
- })();
138
- return [top, ...body, bottom];
375
+ if (lines.length === 0) return [];
376
+ if (!expanded) {
377
+ const first = lines.find((line) => line !== "") ?? "";
378
+ const clipped = lines.length > 1 ? truncateToWidth(first, Math.max(1, innerWidth - 1), "") + "…" : first;
379
+ return [theme.fg(BODY_ROLE, clipped)];
380
+ }
381
+ return lines.map((line) => (line === "" ? "" : theme.fg(BODY_ROLE, line)));
382
+ }
383
+
384
+ // Float panels: the float card chrome. Padding rows that keep the accent bar
385
+ // sit above the heading, between heading and body (only when a body exists)
386
+ // and in the bottom rule's place, centering the content: panelExtraRows taller
387
+ // than neon, with the heading on row 1 (see panelHeaderRow).
388
+ function floatPanel(card: Card, theme: CardTheme, width: number, options: CardRenderOptions): string[] {
389
+ const blank = cardBottom(card.tone, theme, width);
390
+ const body = cardText(card, theme, cardInnerWidth(width), options.expanded).map((line) => cardLine(line, card.tone, theme, width));
391
+ return [blank, panelHeader(card, theme, width, options.hint), ...(body.length > 0 ? [blank, ...body] : []), blank];
392
+ }
393
+
394
+ function fitRow(text: string, width: number): string {
395
+ const clipped = visibleWidth(text) <= width ? text : truncateToWidth(text, width, "…");
396
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
397
+ }
398
+
399
+ // `▎ <glyph> <title> <subtitle>` left and the hint right, ending in the same
400
+ // two columns the body rows keep for their right edge.
401
+ function panelHeader(card: Card, theme: CardTheme, width: number, hint?: string): string {
402
+ const lead = theme.fg(FRAME_ROLE[card.tone], chrome.topLeft) + chrome.topLead;
403
+ const subtitle = card.subtitle ? ` ${theme.fg(SUBTITLE_ROLE, card.subtitle)}` : "";
404
+ const left = `${lead}${theme.fg(TITLE_ROLE[card.tone], `${card.glyph ?? CARD_GLYPH} ${card.title}`)}${subtitle}`;
405
+ const right = hint ? `${theme.fg(SUBTITLE_ROLE, hint)}${chrome.topRight}` : "";
406
+ const gap = width - visibleWidth(left) - visibleWidth(right);
407
+ return right && gap >= 1 ? `${left}${" ".repeat(gap)}${right}` : fitRow(left, width);
139
408
  }
@@ -56,7 +56,8 @@ function rule(length: number): string {
56
56
 
57
57
  export function colorDiff(text: string, theme: ChangesViewTheme): string[] {
58
58
  const lines: string[] = [];
59
- for (const line of text.split("\n")) {
59
+ for (const rawLine of text.split("\n")) {
60
+ const line = sanitizeTerminalText(rawLine.replace(/\r/g, "")).replace(/\t/g, " ");
60
61
  if (line === "" || HEADER_PREFIXES.some((prefix) => line.startsWith(prefix))) continue;
61
62
  if (line.startsWith("@@")) lines.push(theme.fg(ROLE.HUNK, line));
62
63
  else if (line.startsWith("+")) lines.push(theme.fg("toolDiffAdded", line));
@@ -1,9 +1,9 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import { stripAnsi } from "./terminal-theme.ts";
3
+ import { CARD_STYLE, type CardStyle } from "./shell-card.ts";
3
4
 
4
- // Gentle Shell prompt frame. pi's editor renders a top rule, padded content
5
- // lines, and a bottom rule; this module wraps those lines in a rounded frame
6
- // with a petal that shows what the agent is doing. Everything here is pure.
5
+ // Pure prompt chrome: neon keeps Pi's rule/content/rule rows in a rounded
6
+ // frame; float retains those indices as painted status/content/padding.
7
7
 
8
8
  export const PROMPT_STATE = {
9
9
  IDLE: "idle",
@@ -43,6 +43,8 @@ export function scanWorkingText(text: string, tick: number, fg: (role: string, t
43
43
  const PETAL_TONE_FRAMES = [PETAL_TONE.DEEP, PETAL_TONE.SOFT, PETAL_TONE.ROSE, PETAL_TONE.BRIGHT, PETAL_TONE.ROSE, PETAL_TONE.SOFT, PETAL_TONE.DEEP, PETAL_TONE.DEEP] as const;
44
44
 
45
45
  export interface PromptFrameOptions {
46
+ style?: CardStyle;
47
+ bg?: (role: string, text: string) => string;
46
48
  state: PromptState;
47
49
  tick: number;
48
50
  borderColor: (text: string) => string;
@@ -50,11 +52,17 @@ export interface PromptFrameOptions {
50
52
  bold?: (text: string) => string;
51
53
  /**
52
54
  * Overrides the editor's own bottom scroll indicator (e.g. "esc again to
53
- * cancel"). Both share the bottom rule's single label slot; an explicit
55
+ * cancel"). Both share the bottom rule / float hint slot; an explicit
54
56
  * hint always wins because it reflects state the editor cannot render on
55
57
  * its own.
56
58
  */
57
59
  escHint?: string;
60
+ /**
61
+ * Overrides the generic "working…" label while state is WORKING with an
62
+ * explicit, orchestrator-reported ODD phase label (e.g. "exploring…").
63
+ * Ignored outside the WORKING state; undefined falls back to "working…".
64
+ */
65
+ workingLabel?: string;
58
66
  }
59
67
 
60
68
  // A terminal cell cannot grow, so the petal earns presence with weight and
@@ -93,8 +101,13 @@ function rule(length: number): string {
93
101
  return "─".repeat(Math.max(0, length));
94
102
  }
95
103
 
104
+ function stateLabel(options: PromptFrameOptions): string | undefined {
105
+ if (options.state === PROMPT_STATE.WORKING) return options.workingLabel ?? STATE_LABEL[PROMPT_STATE.WORKING];
106
+ return STATE_LABEL[options.state];
107
+ }
108
+
96
109
  function topRule(width: number, options: PromptFrameOptions, indicator: string | undefined): string {
97
- const label = indicator ?? STATE_LABEL[options.state];
110
+ const label = indicator ?? stateLabel(options);
98
111
  const scanning = options.state === PROMPT_STATE.WORKING;
99
112
  const glyph = petalGlyph(options.state, options.tick);
100
113
  const petal = options.fg(petalTone(options.state, options.tick), options.bold ? options.bold(glyph) : glyph);
@@ -120,8 +133,91 @@ function sideRules(line: string, innerWidth: number, options: PromptFrameOptions
120
133
  return options.borderColor("│") + content + options.borderColor("│");
121
134
  }
122
135
 
123
- export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions): string[] {
136
+ export interface PromptLayout {
137
+ width: number;
138
+ nativeWidth: number;
139
+ prefixWidth: number;
140
+ background: string;
141
+ }
142
+
143
+ /** Resolve fallback before native wrapping, selection, completion and mouse geometry. */
144
+ export function resolvePromptLayout(width: number, style?: CardStyle, bg?: PromptFrameOptions["bg"]): PromptLayout {
145
+ width = Math.max(0, Math.floor(width));
146
+ let background = "";
147
+ if (style === CARD_STYLE.FLOAT && width >= 10 && bg) {
148
+ try {
149
+ const painted = bg("toolSuccessBg", " ");
150
+ const match = painted.match(/^((?:\x1b\[[\d;]*m)+) (?:\x1b\[(?:49|0)?m)$/);
151
+ if (match) {
152
+ for (const sgr of match[1].matchAll(/\x1b\[([\d;]*)m/g)) {
153
+ for (const command of promptSgrCommands(sgr[1])) {
154
+ const code = command[0];
155
+ if (code === 0 || code === 49) background = "";
156
+ else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107) ||
157
+ (code === 48 && ((command[1] === 5 && command.length === 3) || (command[1] === 2 && command.length === 5)))) {
158
+ background = `\x1b[${command.join(";")}m`;
159
+ }
160
+ }
161
+ }
162
+ }
163
+ } catch { /* Missing theme background: retain the entire neon path. */ }
164
+ }
165
+ return { width, nativeWidth: Math.max(1, width - (background ? 5 : 2)), prefixWidth: background ? 3 : 1, background };
166
+ }
167
+
168
+ // Extended-colour parameters belong to one command: zero RGB channels
169
+ // must never be interpreted as full styling resets.
170
+ function promptSgrCommands(parameters: string): number[][] {
171
+ const codes = parameters.split(";").map(Number);
172
+ const commands: number[][] = [];
173
+ for (let i = 0; i < codes.length;) {
174
+ const extended = codes[i] === 38 || codes[i] === 48 || codes[i] === 58;
175
+ const length = extended && codes[i + 1] === 2 ? 5 : extended && codes[i + 1] === 5 ? 3 : 1;
176
+ commands.push(codes.slice(i, i + length));
177
+ i += length;
178
+ }
179
+ return commands;
180
+ }
181
+
182
+ // Rearm only the panel background, leaving marker/inversion bytes intact.
183
+ function rearmPromptBackground(text: string, background: string): string {
184
+ return text.replace(/\x1b\[([\d;]*)m/g, (sgr, parameters: string) =>
185
+ promptSgrCommands(parameters).some(([code]) => code === 0 || code === 49) ? sgr + background : sgr);
186
+ }
187
+
188
+ /** Same chrome and horizontal prefix for editable and native completion rows. */
189
+ export function floatPromptRow(line: string, layout: PromptLayout, borderColor: PromptFrameOptions["borderColor"]): string {
190
+ const clipped = truncateToWidth(line, layout.nativeWidth, "");
191
+ const content = borderColor("▎") + " " + clipped + " ".repeat(Math.max(0, layout.nativeWidth - visibleWidth(clipped))) + " ";
192
+ return " " + layout.background + rearmPromptBackground(content, layout.background) + "\x1b[49m ";
193
+ }
194
+
195
+ function floatPromptHint(top: string, bottom: string, options: PromptFrameOptions, width: number): string {
196
+ const glyph = petalGlyph(options.state, options.tick);
197
+ const petal = options.fg(petalTone(options.state, options.tick), options.bold ? options.bold(glyph) : glyph);
198
+ const label = stateLabel(options) ?? "waiting for input";
199
+ let paintedLabel = "";
200
+ if (label) {
201
+ paintedLabel = options.state === PROMPT_STATE.WORKING
202
+ ? scanWorkingText(label, options.tick, options.fg)
203
+ : options.fg(LABEL_ROLE, label);
204
+ }
205
+ const info = [scrollIndicator(top), options.escHint ?? scrollIndicator(bottom)]
206
+ .filter((text): text is string => Boolean(text))
207
+ .map((text) => options.fg(LABEL_ROLE, text));
208
+ const hint = truncateToWidth(" " + petal + (paintedLabel ? " " + paintedLabel : "") + (info.length ? " · " + info.join(" · ") : ""), width, "");
209
+ return hint + " ".repeat(Math.max(0, width - visibleWidth(hint)));
210
+ }
211
+
212
+ export function framePromptLines(lines: string[], width: number, options: PromptFrameOptions, layout = resolvePromptLayout(width, options.style, options.bg)): string[] {
124
213
  width = Math.max(0, Math.floor(width));
214
+ if (layout.background && lines.length >= 2) {
215
+ return [
216
+ floatPromptRow(floatPromptHint(lines[0], lines[lines.length - 1], options, layout.nativeWidth).slice(2), layout, options.borderColor),
217
+ ...lines.slice(1, -1).map((line) => floatPromptRow(line, layout, options.borderColor)),
218
+ floatPromptRow("", layout, options.borderColor),
219
+ ];
220
+ }
125
221
  if (lines.length < 2) return lines.map((line) => truncateToWidth(line, width, ""));
126
222
  if (width < 2) return lines.map((_line, index) => width === 0 ? "" : options.borderColor(index === 0 ? "╭" : index === lines.length - 1 ? "╰" : "│"));
127
223
  const innerWidth = width - 2;