@earendil-works__pi-tui@0.80.7.patch 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. diff --git a/dist/components/editor.d.ts b/dist/components/editor.d.ts
  2. index a6fedc9e3b36d066e34860d040db6df47d88c432..f0b20eb87686215d1d1a54274a7b1ebdf4030ef1 100644
  3. --- a/dist/components/editor.d.ts
  4. +++ b/dist/components/editor.d.ts
  5. @@ -21,7 +21,7 @@ export interface TextChunk {
  6. * When omitted the default Intl.Segmenter is used.
  7. * @returns Array of chunks with text and position information
  8. */
  9. -export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[];
  10. +export declare function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[], continuationWidth?: number): TextChunk[];
  11. export interface EditorTheme {
  12. borderColor: (str: string) => string;
  13. selectList: SelectListTheme;
  14. @@ -29,6 +29,13 @@ export interface EditorTheme {
  15. export interface EditorOptions {
  16. paddingX?: number;
  17. autocompleteMaxVisible?: number;
  18. + /** Omit the editor's horizontal frame. */
  19. + frame?: "horizontal" | "none";
  20. + /** Fixed-width prefixes for the first input row and explicit newlines. Wrapped rows start at the editor edge. */
  21. + prompt?: {
  22. + first: string;
  23. + continuation: string;
  24. + };
  25. }
  26. export declare class Editor implements Component, Focusable {
  27. private state;
  28. @@ -79,6 +86,11 @@ export declare class Editor implements Component, Focusable {
  29. getAutocompleteMaxVisible(): number;
  30. setAutocompleteMaxVisible(maxVisible: number): void;
  31. setAutocompleteProvider(provider: AutocompleteProvider): void;
  32. + /** Replace fixed-width first and continuation input prefixes. */
  33. + setPrompt(prompt: {
  34. + first: string;
  35. + continuation: string;
  36. + }): void;
  37. /**
  38. * Add a prompt to history for up/down arrow navigation.
  39. * Called after successful submission.
  40. diff --git a/dist/components/editor.js b/dist/components/editor.js
  41. index 6c03aeec4148571558713e885ac7f7df18a511bc..0111317ccab31a1e973b92272d8a668b75a29174 100644
  42. --- a/dist/components/editor.js
  43. +++ b/dist/components/editor.js
  44. @@ -79,7 +79,7 @@ function segmentWithMarkers(text, baseSegmenter, validIds) {
  45. * When omitted the default Intl.Segmenter is used.
  46. * @returns Array of chunks with text and position information
  47. */
  48. -export function wordWrapLine(line, maxWidth, preSegmented) {
  49. +export function wordWrapLine(line, maxWidth, preSegmented, continuationWidth = maxWidth) {
  50. if (!line || maxWidth <= 0) {
  51. return [{ text: "", startIndex: 0, endIndex: 0 }];
  52. }
  53. @@ -90,6 +90,7 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
  54. const chunks = [];
  55. const segments = preSegmented ?? [...graphemeSegmenter.segment(line)];
  56. let currentWidth = 0;
  57. + let currentMaxWidth = maxWidth;
  58. let chunkStart = 0;
  59. // Wrap opportunity: the position after the last whitespace before a non-whitespace
  60. // grapheme, i.e. where a line break is allowed.
  61. @@ -102,11 +103,12 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
  62. const charIndex = seg.index;
  63. const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
  64. // Overflow check before advancing.
  65. - if (currentWidth + gWidth > maxWidth) {
  66. - if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= maxWidth) {
  67. + if (currentWidth + gWidth > currentMaxWidth) {
  68. + if (wrapOppIndex >= 0 && currentWidth - wrapOppWidth + gWidth <= continuationWidth) {
  69. // Backtrack to last wrap opportunity (the remaining content
  70. // plus the current grapheme still fits within maxWidth).
  71. chunks.push({ text: line.slice(chunkStart, wrapOppIndex), startIndex: chunkStart, endIndex: wrapOppIndex });
  72. + currentMaxWidth = continuationWidth;
  73. chunkStart = wrapOppIndex;
  74. currentWidth -= wrapOppWidth;
  75. }
  76. @@ -117,22 +119,29 @@ export function wordWrapLine(line, maxWidth, preSegmented) {
  77. // the current grapheme (e.g. a wide character) still exceeds
  78. // maxWidth.
  79. chunks.push({ text: line.slice(chunkStart, charIndex), startIndex: chunkStart, endIndex: charIndex });
  80. + currentMaxWidth = continuationWidth;
  81. chunkStart = charIndex;
  82. currentWidth = 0;
  83. }
  84. wrapOppIndex = -1;
  85. }
  86. - if (gWidth > maxWidth) {
  87. - // Single atomic segment wider than maxWidth (e.g. paste marker
  88. + if (gWidth > currentMaxWidth) {
  89. + if (segments.length === 1) {
  90. + chunks.push({ text: grapheme, startIndex: charIndex, endIndex: charIndex + grapheme.length });
  91. + return chunks;
  92. + }
  93. + // Single atomic segment wider than the current line width (e.g. paste marker
  94. // in a narrow terminal). Re-wrap it at grapheme granularity.
  95. // The segment remains logically atomic for cursor
  96. // movement / editing — the split is purely visual for word-wrap layout.
  97. - const subChunks = wordWrapLine(grapheme, maxWidth);
  98. + const subChunks = wordWrapLine(grapheme, currentMaxWidth, undefined, continuationWidth);
  99. for (let j = 0; j < subChunks.length - 1; j++) {
  100. const sc = subChunks[j];
  101. chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
  102. }
  103. const last = subChunks[subChunks.length - 1];
  104. + if (subChunks.length > 1)
  105. + currentMaxWidth = continuationWidth;
  106. chunkStart = charIndex + last.startIndex;
  107. currentWidth = visibleWidth(last.text);
  108. wrapOppIndex = -1;
  109. @@ -189,8 +198,12 @@ export class Editor {
  110. tui;
  111. theme;
  112. paddingX = 0;
  113. + frame = "horizontal";
  114. + prompt;
  115. + promptWidth = 0;
  116. // Store last render width for cursor navigation
  117. lastWidth = 80;
  118. + lastContinuationWidth = 80;
  119. // Vertical scrolling support
  120. scrollOffset = 0;
  121. // Border color (can be changed dynamically)
  122. @@ -243,9 +256,29 @@ export class Editor {
  123. this.borderColor = theme.borderColor;
  124. const paddingX = options.paddingX ?? 0;
  125. this.paddingX = Number.isFinite(paddingX) ? Math.max(0, Math.floor(paddingX)) : 0;
  126. + this.frame = options.frame ?? "horizontal";
  127. + this.prompt = options.prompt;
  128. + if (this.prompt) {
  129. + const firstWidth = visibleWidth(this.prompt.first);
  130. + const continuationWidth = visibleWidth(this.prompt.continuation);
  131. + if (firstWidth !== continuationWidth) {
  132. + throw new Error("Editor prompt prefixes must have equal visible widths");
  133. + }
  134. + this.promptWidth = firstWidth;
  135. + }
  136. const maxVisible = options.autocompleteMaxVisible ?? 5;
  137. this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
  138. }
  139. + setPrompt(prompt) {
  140. + const firstWidth = visibleWidth(prompt.first);
  141. + const continuationWidth = visibleWidth(prompt.continuation);
  142. + if (firstWidth !== continuationWidth) {
  143. + throw new Error("Editor prompt prefixes must have equal visible widths");
  144. + }
  145. + this.prompt = prompt;
  146. + this.promptWidth = firstWidth;
  147. + this.invalidate();
  148. + }
  149. /** Set of currently valid paste IDs, for marker-aware segmentation. */
  150. validPasteIds() {
  151. return new Set(this.pastes.keys());
  152. @@ -364,14 +397,17 @@ export class Editor {
  153. const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
  154. const paddingX = Math.min(this.paddingX, maxPadding);
  155. const contentWidth = Math.max(1, width - paddingX * 2);
  156. + const inputWidth = Math.max(1, contentWidth - this.promptWidth);
  157. // Layout width: with padding the cursor can overflow into it,
  158. // without padding we reserve 1 column for the cursor.
  159. - const layoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
  160. - // Store for cursor navigation (must match wrapping width)
  161. + const layoutWidth = Math.max(1, inputWidth - (paddingX ? 0 : 1));
  162. + const continuationLayoutWidth = Math.max(1, contentWidth - (paddingX ? 0 : 1));
  163. + // Store for cursor navigation (must match wrapping widths)
  164. this.lastWidth = layoutWidth;
  165. + this.lastContinuationWidth = continuationLayoutWidth;
  166. const horizontal = this.borderColor("─");
  167. // Layout the text
  168. - const layoutLines = this.layoutText(layoutWidth);
  169. + const layoutLines = this.layoutText(layoutWidth, continuationLayoutWidth);
  170. // Calculate max visible lines: 30% of terminal height, minimum 5 lines
  171. const terminalRows = this.tui.terminal.rows;
  172. const maxVisibleLines = Math.max(5, Math.floor(terminalRows * 0.3));
  173. @@ -396,16 +432,22 @@ export class Editor {
  174. const rightPadding = leftPadding;
  175. // Render top border (with scroll indicator if scrolled down)
  176. if (this.scrollOffset > 0) {
  177. - const indicator = `─── ↑ ${this.scrollOffset} more `;
  178. - const remaining = width - visibleWidth(indicator);
  179. - if (remaining >= 0) {
  180. - result.push(this.borderColor(indicator + "─".repeat(remaining)));
  181. + if (this.frame === "none") {
  182. + const indicator = `${" ".repeat(this.promptWidth)}↑ ${this.scrollOffset} more`;
  183. + result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`);
  184. }
  185. else {
  186. - result.push(this.borderColor(truncateToWidth(indicator, width)));
  187. + const indicator = `─── ↑ ${this.scrollOffset} more `;
  188. + const remaining = width - visibleWidth(indicator);
  189. + if (remaining >= 0) {
  190. + result.push(this.borderColor(indicator + "─".repeat(remaining)));
  191. + }
  192. + else {
  193. + result.push(this.borderColor(truncateToWidth(indicator, width)));
  194. + }
  195. }
  196. }
  197. - else {
  198. + else if (this.frame === "horizontal") {
  199. result.push(horizontal.repeat(width));
  200. }
  201. // Render each visible layout line
  202. @@ -413,7 +455,19 @@ export class Editor {
  203. // hardware cursor for IME candidate-window placement even while
  204. // autocomplete (e.g. slash-command menu) is visible.
  205. const emitCursorMarker = this.focused;
  206. - for (const layoutLine of visibleLines) {
  207. + for (let visibleIndex = 0; visibleIndex < visibleLines.length; visibleIndex++) {
  208. + const layoutLine = visibleLines[visibleIndex];
  209. + if (!layoutLine)
  210. + continue;
  211. + const absoluteIndex = this.scrollOffset + visibleIndex;
  212. + const prefix = this.prompt
  213. + ? (absoluteIndex === 0
  214. + ? this.prompt.first
  215. + : layoutLine.isContinuation
  216. + ? ""
  217. + : this.prompt.continuation)
  218. + : "";
  219. + const lineContentWidth = inputWidth + (layoutLine.isContinuation ? this.promptWidth : 0);
  220. let displayText = layoutLine.text;
  221. let lineVisibleWidth = visibleWidth(layoutLine.text);
  222. let cursorInPadding = false;
  223. @@ -439,34 +493,41 @@ export class Editor {
  224. displayText = before + marker + cursor;
  225. lineVisibleWidth = lineVisibleWidth + 1;
  226. // If cursor overflows content width into the padding, flag it
  227. - if (lineVisibleWidth > contentWidth && paddingX > 0) {
  228. + if (lineVisibleWidth > lineContentWidth && paddingX > 0) {
  229. cursorInPadding = true;
  230. }
  231. }
  232. }
  233. // Calculate padding based on actual visible width
  234. - const padding = " ".repeat(Math.max(0, contentWidth - lineVisibleWidth));
  235. + const padding = " ".repeat(Math.max(0, lineContentWidth - lineVisibleWidth));
  236. const lineRightPadding = cursorInPadding ? rightPadding.slice(1) : rightPadding;
  237. // Render the line (no side borders, just horizontal lines above and below)
  238. - result.push(`${leftPadding}${displayText}${padding}${lineRightPadding}`);
  239. + result.push(`${leftPadding}${prefix}${displayText}${padding}${lineRightPadding}`);
  240. }
  241. // Render bottom border (with scroll indicator if more content below)
  242. const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length);
  243. if (linesBelow > 0) {
  244. - const indicator = `─── ↓ ${linesBelow} more `;
  245. - const remaining = width - visibleWidth(indicator);
  246. - result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
  247. + if (this.frame === "none") {
  248. + const indicator = `${" ".repeat(this.promptWidth)}↓ ${linesBelow} more`;
  249. + result.push(`${leftPadding}${this.borderColor(indicator)}${" ".repeat(Math.max(0, contentWidth - visibleWidth(indicator)))}${rightPadding}`);
  250. + }
  251. + else {
  252. + const indicator = `─── ↓ ${linesBelow} more `;
  253. + const remaining = width - visibleWidth(indicator);
  254. + result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining))));
  255. + }
  256. }
  257. - else {
  258. + else if (this.frame === "horizontal") {
  259. result.push(horizontal.repeat(width));
  260. }
  261. // Add autocomplete list if active
  262. if (this.autocompleteState && this.autocompleteList) {
  263. - const autocompleteResult = this.autocompleteList.render(contentWidth);
  264. + const autocompleteResult = this.autocompleteList.render(inputWidth);
  265. + const autocompletePrefix = " ".repeat(this.promptWidth);
  266. for (const line of autocompleteResult) {
  267. const lineWidth = visibleWidth(line);
  268. - const linePadding = " ".repeat(Math.max(0, contentWidth - lineWidth));
  269. - result.push(`${leftPadding}${line}${linePadding}${rightPadding}`);
  270. + const linePadding = " ".repeat(Math.max(0, inputWidth - lineWidth));
  271. + result.push(`${leftPadding}${autocompletePrefix}${line}${linePadding}${rightPadding}`);
  272. }
  273. }
  274. return result;
  275. @@ -726,7 +787,7 @@ export class Editor {
  276. this.insertCharacter(data);
  277. }
  278. }
  279. - layoutText(contentWidth) {
  280. + layoutText(contentWidth, continuationWidth) {
  281. const layoutLines = [];
  282. if (this.state.lines.length === 0 || (this.state.lines.length === 1 && this.state.lines[0] === "")) {
  283. // Empty editor
  284. @@ -734,6 +795,7 @@ export class Editor {
  285. text: "",
  286. hasCursor: true,
  287. cursorPos: 0,
  288. + isContinuation: false,
  289. });
  290. return layoutLines;
  291. }
  292. @@ -749,18 +811,20 @@ export class Editor {
  293. text: line,
  294. hasCursor: true,
  295. cursorPos: this.state.cursorCol,
  296. + isContinuation: false,
  297. });
  298. }
  299. else {
  300. layoutLines.push({
  301. text: line,
  302. hasCursor: false,
  303. + isContinuation: false,
  304. });
  305. }
  306. }
  307. else {
  308. // Line needs wrapping - use word-aware wrapping
  309. - const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")]);
  310. + const chunks = wordWrapLine(line, contentWidth, [...this.segment(line, "grapheme")], continuationWidth);
  311. for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
  312. const chunk = chunks[chunkIndex];
  313. if (!chunk)
  314. @@ -796,12 +860,14 @@ export class Editor {
  315. text: chunk.text,
  316. hasCursor: true,
  317. cursorPos: adjustedCursorPos,
  318. + isContinuation: chunkIndex > 0,
  319. });
  320. }
  321. else {
  322. layoutLines.push({
  323. text: chunk.text,
  324. hasCursor: false,
  325. + isContinuation: chunkIndex > 0,
  326. });
  327. }
  328. }
  329. @@ -1439,7 +1505,7 @@ export class Editor {
  330. * - startCol: starting column in the logical line
  331. * - length: length of this visual line segment
  332. */
  333. - buildVisualLineMap(width) {
  334. + buildVisualLineMap(width, continuationWidth = this.lastContinuationWidth) {
  335. const visualLines = [];
  336. for (let i = 0; i < this.state.lines.length; i++) {
  337. const line = this.state.lines[i] || "";
  338. @@ -1453,7 +1519,7 @@ export class Editor {
  339. }
  340. else {
  341. // Line needs wrapping - use word-aware wrapping
  342. - const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")]);
  343. + const chunks = wordWrapLine(line, width, [...this.segment(line, "grapheme")], continuationWidth);
  344. for (const chunk of chunks) {
  345. visualLines.push({
  346. logicalLine: i,