@oh-my-pi/pi-coding-agent 9.6.1 → 9.6.3

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 (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/docs/theme.md +9 -12
  3. package/package.json +7 -7
  4. package/src/exa/render.ts +12 -12
  5. package/src/ipy/gateway-coordinator.ts +1 -1
  6. package/src/ipy/runtime.ts +2 -6
  7. package/src/lsp/render.ts +16 -30
  8. package/src/modes/components/bash-execution.ts +2 -4
  9. package/src/modes/components/custom-message.ts +1 -1
  10. package/src/modes/components/footer.ts +1 -1
  11. package/src/modes/components/history-search.ts +3 -2
  12. package/src/modes/components/hook-message.ts +1 -1
  13. package/src/modes/components/python-execution.ts +2 -4
  14. package/src/modes/components/read-tool-group.ts +1 -1
  15. package/src/modes/components/session-selector.ts +7 -11
  16. package/src/modes/components/status-line/segments.ts +1 -1
  17. package/src/modes/components/status-line.ts +1 -1
  18. package/src/modes/components/tool-execution.ts +3 -3
  19. package/src/modes/components/ttsr-notification.ts +1 -1
  20. package/src/modes/components/welcome.ts +2 -2
  21. package/src/modes/controllers/event-controller.ts +3 -3
  22. package/src/modes/interactive-mode.ts +1 -1
  23. package/src/modes/theme/theme.ts +0 -11
  24. package/src/patch/shared.ts +7 -10
  25. package/src/task/executor.ts +1 -1
  26. package/src/task/render.ts +31 -33
  27. package/src/tools/bash.ts +3 -3
  28. package/src/tools/calculator.ts +3 -3
  29. package/src/tools/fetch.ts +4 -6
  30. package/src/tools/python.ts +22 -31
  31. package/src/tools/read.ts +1 -1
  32. package/src/tools/render-utils.ts +12 -21
  33. package/src/tools/review.ts +1 -7
  34. package/src/tools/ssh.ts +6 -8
  35. package/src/tools/write.ts +5 -5
  36. package/src/tui/code-cell.ts +2 -2
  37. package/src/tui/output-block.ts +2 -2
  38. package/src/tui/tree-list.ts +1 -3
  39. package/src/tui/utils.ts +3 -5
  40. package/src/web/search/render.ts +14 -24
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [9.6.2] - 2026-02-01
6
+ ### Changed
7
+
8
+ - Replaced hardcoded ellipsis strings with Unicode ellipsis character (…) throughout rendering code
9
+ - Removed `format.ellipsis` symbol from theme configuration; ellipsis now uses literal Unicode character
10
+ - Updated `truncate()` function to `truncateToWidth()` with simplified API accepting default ellipsis parameter
11
+ - Simplified `formatMoreItems()` function signature by removing theme parameter dependency
12
+
13
+ ### Removed
14
+
15
+ - Removed `format.ellipsis` symbol key from theme symbol maps (Unicode, Nerd, and ASCII presets)
16
+ - Removed `ellipsis` property from `SymbolTheme` type
17
+
5
18
  ## [9.6.1] - 2026-02-01
6
19
 
7
20
  ### Fixed
package/docs/theme.md CHANGED
@@ -157,14 +157,14 @@ Example:
157
157
 
158
158
  ```json
159
159
  {
160
- "symbols": {
161
- "preset": "ascii",
162
- "overrides": {
163
- "icon.model": "[M]",
164
- "sep.powerlineLeft": ">",
165
- "sep.powerlineRight": "<"
166
- }
167
- }
160
+ "symbols": {
161
+ "preset": "ascii",
162
+ "overrides": {
163
+ "icon.model": "[M]",
164
+ "sep.powerlineLeft": ">",
165
+ "sep.powerlineRight": "<"
166
+ }
167
+ }
168
168
  }
169
169
  ```
170
170
 
@@ -179,7 +179,7 @@ Symbol keys by category:
179
179
  - Icons: `icon.model`, `icon.folder`, `icon.file`, `icon.git`, `icon.branch`, `icon.tokens`, `icon.context`, `icon.cost`, `icon.time`, `icon.pi`, `icon.agents`, `icon.cache`, `icon.input`, `icon.output`, `icon.host`, `icon.session`, `icon.package`, `icon.warning`, `icon.rewind`, `icon.auto`, `icon.extensionSkill`, `icon.extensionTool`, `icon.extensionSlashCommand`, `icon.extensionMcp`, `icon.extensionRule`, `icon.extensionHook`, `icon.extensionPrompt`, `icon.extensionContextFile`, `icon.extensionInstruction`
180
180
  - Thinking: `thinking.minimal`, `thinking.low`, `thinking.medium`, `thinking.high`, `thinking.xhigh`
181
181
  - Checkboxes: `checkbox.checked`, `checkbox.unchecked`
182
- - Formatting: `format.ellipsis`, `format.bullet`, `format.dash`
182
+ - Formatting: `format.bullet`, `format.dash`
183
183
  - Markdown: `md.quoteBorder`, `md.hrChar`, `md.bullet`
184
184
 
185
185
  ### Color Values
@@ -620,7 +620,6 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello"));
620
620
  **Color resolution:**
621
621
 
622
622
  1. **Detect terminal capabilities:**
623
-
624
623
  - Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support)
625
624
  - Check `$TERM` env var (`*-256color` → 256-color support)
626
625
  - Fallback to 256-color mode if detection fails
@@ -644,13 +643,11 @@ const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello"));
644
643
  4. **Convert colors to ANSI codes based on terminal capability:**
645
644
 
646
645
  **Truecolor mode (24-bit):**
647
-
648
646
  - Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m`
649
647
  - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
650
648
  - Empty string (`""`) → `\x1b[39m`
651
649
 
652
650
  **256-color mode:**
653
-
654
651
  - Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m`
655
652
  - 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
656
653
  - Empty string (`""`) → `\x1b[39m`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "9.6.1",
3
+ "version": "9.6.3",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -79,12 +79,12 @@
79
79
  "test": "bun test"
80
80
  },
81
81
  "dependencies": {
82
- "@oh-my-pi/omp-stats": "9.6.1",
83
- "@oh-my-pi/pi-agent-core": "9.6.1",
84
- "@oh-my-pi/pi-ai": "9.6.1",
85
- "@oh-my-pi/pi-natives": "9.6.1",
86
- "@oh-my-pi/pi-tui": "9.6.1",
87
- "@oh-my-pi/pi-utils": "9.6.1",
82
+ "@oh-my-pi/omp-stats": "9.6.3",
83
+ "@oh-my-pi/pi-agent-core": "9.6.3",
84
+ "@oh-my-pi/pi-ai": "9.6.3",
85
+ "@oh-my-pi/pi-natives": "9.6.3",
86
+ "@oh-my-pi/pi-tui": "9.6.3",
87
+ "@oh-my-pi/pi-utils": "9.6.3",
88
88
  "@openai/agents": "^0.4.4",
89
89
  "@sinclair/typebox": "^0.34.48",
90
90
  "ajv": "^8.17.1",
package/src/exa/render.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  getPreviewLines,
18
18
  PREVIEW_LIMITS,
19
19
  TRUNCATE_LENGTHS,
20
- truncate,
20
+ truncateToWidth,
21
21
  } from "../tools/render-utils";
22
22
  import type { ExaRenderDetails } from "./types";
23
23
 
@@ -72,14 +72,14 @@ export function renderExaResult(
72
72
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
73
73
  text += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg(
74
74
  "toolOutput",
75
- truncate(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis),
75
+ truncateToWidth(displayLines[i], COLLAPSED_PREVIEW_LINE_LEN),
76
76
  )}`;
77
77
  }
78
78
 
79
79
  if (remaining > 0) {
80
80
  text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.fg(
81
81
  "muted",
82
- formatMoreItems(remaining, "line", uiTheme),
82
+ formatMoreItems(remaining, "line"),
83
83
  )}`;
84
84
  }
85
85
 
@@ -119,17 +119,17 @@ export function renderExaResult(
119
119
  const first = results[0];
120
120
  const previewText = first.text ?? first.title ?? "";
121
121
  const previewLines = previewText
122
- ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN, uiTheme.format.ellipsis)
122
+ ? getPreviewLines(previewText, COLLAPSED_PREVIEW_LINES, COLLAPSED_PREVIEW_LINE_LEN)
123
123
  : [];
124
124
  const safePreviewLines = previewLines.length > 0 ? previewLines : ["No preview text"];
125
125
  const totalLines = previewText.split("\n").filter(l => l.trim()).length;
126
126
  const remainingLines = Math.max(0, totalLines - previewLines.length);
127
127
  const extraItems: string[] = [];
128
128
  if (remainingLines > 0) {
129
- extraItems.push(formatMoreItems(remainingLines, "line", uiTheme));
129
+ extraItems.push(formatMoreItems(remainingLines, "line"));
130
130
  }
131
131
  if (resultCount > 1) {
132
- extraItems.push(formatMoreItems(resultCount - 1, "result", uiTheme));
132
+ extraItems.push(formatMoreItems(resultCount - 1, "result"));
133
133
  }
134
134
 
135
135
  for (let i = 0; i < safePreviewLines.length; i++) {
@@ -160,7 +160,7 @@ export function renderExaResult(
160
160
  const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
161
161
  const cont = isLast ? " " : uiTheme.tree.vertical;
162
162
 
163
- const title = truncate(res.title ?? "Untitled", MAX_TITLE_LEN, uiTheme.format.ellipsis);
163
+ const title = truncateToWidth(res.title ?? "Untitled", MAX_TITLE_LEN);
164
164
  const domain = res.url ? getDomain(res.url) : "";
165
165
  const domainPart = domain ? uiTheme.fg("dim", ` (${domain})`) : "";
166
166
 
@@ -193,13 +193,13 @@ export function renderExaResult(
193
193
  for (const line of displayLines) {
194
194
  text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
195
195
  "toolOutput",
196
- truncate(line.trim(), EXPANDED_TEXT_LINE_LEN, uiTheme.format.ellipsis),
196
+ truncateToWidth(line.trim(), EXPANDED_TEXT_LINE_LEN),
197
197
  )}`;
198
198
  }
199
199
  if (textLines.length > EXPANDED_TEXT_LINES) {
200
200
  text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
201
201
  "muted",
202
- formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line", uiTheme),
202
+ formatMoreItems(textLines.length - EXPANDED_TEXT_LINES, "line"),
203
203
  )}`;
204
204
  }
205
205
  }
@@ -214,13 +214,13 @@ export function renderExaResult(
214
214
  const h = res.highlights[j];
215
215
  text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
216
216
  "muted",
217
- `${uiTheme.format.dash} ${truncate(h, MAX_HIGHLIGHT_LEN, uiTheme.format.ellipsis)}`,
217
+ `${uiTheme.format.dash} ${truncateToWidth(h, MAX_HIGHLIGHT_LEN)}`,
218
218
  )}`;
219
219
  }
220
220
  if (res.highlights.length > maxHighlights) {
221
221
  text += `\n ${uiTheme.fg("dim", cont)} ${uiTheme.fg("dim", uiTheme.tree.hook)} ${uiTheme.fg(
222
222
  "muted",
223
- formatMoreItems(res.highlights.length - maxHighlights, "highlight", uiTheme),
223
+ formatMoreItems(res.highlights.length - maxHighlights, "highlight"),
224
224
  )}`;
225
225
  }
226
226
  }
@@ -232,7 +232,7 @@ export function renderExaResult(
232
232
  /** Render Exa call (query/args preview) */
233
233
  export function renderExaCall(args: Record<string, unknown>, toolName: string, uiTheme: Theme): Component {
234
234
  const toolLabel = toolName || "Exa Search";
235
- const query = typeof args.query === "string" ? truncate(args.query, 80, uiTheme.format.ellipsis) : "?";
235
+ const query = typeof args.query === "string" ? truncateToWidth(args.query, 80) : "?";
236
236
  const numResults = typeof args.num_results === "number" ? args.num_results : undefined;
237
237
 
238
238
  let text = `${uiTheme.fg("toolTitle", toolLabel)} ${uiTheme.fg("accent", query)}`;
@@ -251,7 +251,7 @@ async function startGatewayProcess(
251
251
 
252
252
  const gatewayProcess = Bun.spawn(
253
253
  [
254
- runtime.pythonwPath,
254
+ runtime.pythonPath,
255
255
  "-m",
256
256
  "kernel_gateway",
257
257
  "--KernelGatewayApp.ip=127.0.0.1",
@@ -104,8 +104,6 @@ function resolvePathKey(env: Record<string, string | undefined>): string {
104
104
  export interface PythonRuntime {
105
105
  /** Path to python executable */
106
106
  pythonPath: string;
107
- /** Path to windowless python executable (pythonw.exe on Windows, same as pythonPath otherwise) */
108
- pythonwPath: string;
109
107
  /** Filtered environment variables */
110
108
  env: Record<string, string | undefined>;
111
109
  /** Path to virtual environment, if detected */
@@ -177,8 +175,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
177
175
  const currentPath = env[pathKey];
178
176
  env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
179
177
  return {
180
- pythonPath: pythonCandidate,
181
- pythonwPath: resolveWindowlessPython(pythonCandidate),
178
+ pythonPath: resolveWindowlessPython(pythonCandidate),
182
179
  env,
183
180
  venvPath,
184
181
  };
@@ -190,8 +187,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
190
187
  throw new Error("Python executable not found on PATH");
191
188
  }
192
189
  return {
193
- pythonPath,
194
- pythonwPath: resolveWindowlessPython(pythonPath),
190
+ pythonPath: resolveWindowlessPython(pythonPath),
195
191
  env,
196
192
  venvPath: null,
197
193
  };
package/src/lsp/render.ts CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  formatStatusIcon,
18
18
  shortenPath,
19
19
  TRUNCATE_LENGTHS,
20
- truncate,
20
+ truncateToWidth,
21
21
  } from "../tools/render-utils";
22
22
  import { renderOutputBlock, renderStatusLine } from "../tui";
23
23
  import type { LspParams, LspToolDetails } from "./types";
@@ -32,10 +32,8 @@ import type { LspParams, LspToolDetails } from "./types";
32
32
  */
33
33
  export function renderCall(args: LspParams, theme: Theme): Text {
34
34
  const actionLabel = (args.action ?? "request").replace(/_/g, " ");
35
- const queryPreview = args.query ? truncate(args.query, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis) : undefined;
36
- const replacementPreview = args.replacement
37
- ? truncate(args.replacement, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis)
38
- : undefined;
35
+ const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined;
36
+ const replacementPreview = args.replacement ? truncateToWidth(args.replacement, TRUNCATE_LENGTHS.SHORT) : undefined;
39
37
 
40
38
  let target: string | undefined;
41
39
  let hasFileTarget = false;
@@ -265,7 +263,7 @@ function renderHover(
265
263
 
266
264
  let output = `${icon}${langLabel}${expandHint}`;
267
265
  if (beforeCode) {
268
- const preview = truncate(beforeCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis);
266
+ const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE);
269
267
  output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`;
270
268
  }
271
269
  const h = theme.boxSharp.horizontal;
@@ -274,14 +272,11 @@ function renderHover(
274
272
  output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`;
275
273
 
276
274
  if (codeLines.length > 1) {
277
- output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg(
278
- "muted",
279
- `${theme.format.ellipsis} ${codeLines.length - 1} more lines`,
280
- )}`;
275
+ output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg("muted", `… ${codeLines.length - 1} more lines`)}`;
281
276
  }
282
277
 
283
278
  if (afterCode) {
284
- const docPreview = truncate(afterCode, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis);
279
+ const docPreview = truncateToWidth(afterCode, TRUNCATE_LENGTHS.TITLE);
285
280
  output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`;
286
281
  } else {
287
282
  output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`;
@@ -377,7 +372,7 @@ function renderDiagnostics(
377
372
  if (item.message) {
378
373
  output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg(
379
374
  "muted",
380
- truncate(item.message, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis),
375
+ truncateToWidth(item.message, TRUNCATE_LENGTHS.LINE),
381
376
  )}`;
382
377
  }
383
378
  }
@@ -402,15 +397,12 @@ function renderDiagnostics(
402
397
  const severityColor = severityToColor(item.severity);
403
398
  const location = formatDiagnosticLocation(item.file, item.line, item.col, theme);
404
399
  const message = item.message
405
- ? ` ${theme.fg("muted", truncate(item.message, TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis))}`
400
+ ? ` ${theme.fg("muted", truncateToWidth(item.message, TRUNCATE_LENGTHS.CONTENT))}`
406
401
  : "";
407
402
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`;
408
403
  }
409
404
  if (remaining > 0) {
410
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
411
- "muted",
412
- `${theme.format.ellipsis} ${remaining} more`,
413
- )}`;
405
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${remaining} more`)}`;
414
406
  }
415
407
 
416
408
  return output.split("\n");
@@ -473,14 +465,14 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
473
465
  const context = `at ${file}:${line}:${col}`;
474
466
  output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg(
475
467
  "muted",
476
- truncate(context, TRUNCATE_LENGTHS.LINE, theme.format.ellipsis),
468
+ truncateToWidth(context, TRUNCATE_LENGTHS.LINE),
477
469
  )}`;
478
470
  }
479
471
  }
480
472
  if (locs.length > maxLocsPerFile) {
481
473
  output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", theme.tree.last)} ${theme.fg(
482
474
  "muted",
483
- `${theme.format.ellipsis} ${locs.length - maxLocsPerFile} more`,
475
+ `… ${locs.length - maxLocsPerFile} more`,
484
476
  )}`;
485
477
  }
486
478
  }
@@ -489,7 +481,7 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
489
481
  if (files.length > maxFiles) {
490
482
  output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
491
483
  "muted",
492
- formatMoreItems(files.length - maxFiles, "file", theme),
484
+ formatMoreItems(files.length - maxFiles, "file"),
493
485
  )}`;
494
486
  }
495
487
 
@@ -596,10 +588,7 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
596
588
  )}`;
597
589
  }
598
590
  if (topLevelCount > 3) {
599
- output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
600
- "muted",
601
- `${theme.format.ellipsis} ${topLevelCount - 3} more`,
602
- )}`;
591
+ output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${topLevelCount - 3} more`)}`;
603
592
  }
604
593
 
605
594
  return output.split("\n");
@@ -635,10 +624,7 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
635
624
 
636
625
  const firstLine = lines[0] || "No output";
637
626
  const expandHint = formatExpandHint(theme, expanded, lines.length > 1);
638
- let output = `${icon} ${theme.fg(
639
- "dim",
640
- truncate(firstLine, TRUNCATE_LENGTHS.TITLE, theme.format.ellipsis),
641
- )}${expandHint}`;
627
+ let output = `${icon} ${theme.fg("dim", truncateToWidth(firstLine, TRUNCATE_LENGTHS.TITLE))}${expandHint}`;
642
628
 
643
629
  if (lines.length > 1) {
644
630
  const previewLines = lines.slice(1, 4);
@@ -647,13 +633,13 @@ function renderGeneric(text: string, lines: string[], expanded: boolean, theme:
647
633
  const branch = isLast ? theme.tree.last : theme.tree.branch;
648
634
  output += `\n ${theme.fg("dim", branch)} ${theme.fg(
649
635
  "dim",
650
- truncate(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT, theme.format.ellipsis),
636
+ truncateToWidth(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT),
651
637
  )}`;
652
638
  }
653
639
  if (lines.length > 4) {
654
640
  output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
655
641
  "muted",
656
- formatMoreItems(lines.length - 4, "line", theme),
642
+ formatMoreItems(lines.length - 4, "line"),
657
643
  )}`;
658
644
  }
659
645
  }
@@ -48,7 +48,7 @@ export class BashExecutionComponent extends Container {
48
48
  ui,
49
49
  spinner => theme.fg(colorKey, spinner),
50
50
  text => theme.fg("muted", text),
51
- `Running${theme.format.ellipsis} (esc to cancel)`,
51
+ `Running (esc to cancel)`,
52
52
  getSymbolTheme().spinnerFrames,
53
53
  );
54
54
  this.contentContainer.addChild(this.loader);
@@ -150,9 +150,7 @@ export class BashExecutionComponent extends Container {
150
150
 
151
151
  // Show how many lines are hidden (collapsed preview)
152
152
  if (hiddenLineCount > 0) {
153
- statusParts.push(
154
- theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`),
155
- );
153
+ statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
156
154
  }
157
155
 
158
156
  if (this.status === "cancelled") {
@@ -87,7 +87,7 @@ export class CustomMessageComponent extends Container {
87
87
  if (!this._expanded) {
88
88
  const lines = text.split("\n");
89
89
  if (lines.length > 5) {
90
- text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`;
90
+ text = `${lines.slice(0, 5).join("\n")}\n…`;
91
91
  }
92
92
  }
93
93
 
@@ -317,7 +317,7 @@ export class FooterComponent implements Component {
317
317
  .map(([, text]) => sanitizeStatusText(text));
318
318
  const statusLine = sortedStatuses.join(" ");
319
319
  // Truncate to terminal width with dim ellipsis for consistency with footer style
320
- lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "...")));
320
+ lines.push(truncateToWidth(statusLine, width));
321
321
  }
322
322
 
323
323
  return lines;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type Component,
3
3
  Container,
4
+ Ellipsis,
4
5
  Input,
5
6
  matchesKey,
6
7
  padding,
@@ -55,13 +56,13 @@ class HistoryResultsList implements Component {
55
56
  const maxWidth = width - cursorWidth;
56
57
 
57
58
  const normalized = entry.prompt.replace(/\s+/g, " ").trim();
58
- const truncated = truncateToWidth(normalized, maxWidth, theme.format.ellipsis);
59
+ const truncated = truncateToWidth(normalized, maxWidth);
59
60
  lines.push(cursor + (isSelected ? theme.bold(truncated) : truncated));
60
61
  }
61
62
 
62
63
  if (startIndex > 0 || endIndex < this.results.length) {
63
64
  const scrollText = ` (${this.selectedIndex + 1}/${this.results.length})`;
64
- lines.push(theme.fg("muted", truncateToWidth(scrollText, width, "")));
65
+ lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit)));
65
66
  }
66
67
 
67
68
  return lines;
@@ -88,7 +88,7 @@ export class HookMessageComponent extends Container {
88
88
  if (!this._expanded) {
89
89
  const lines = text.split("\n");
90
90
  if (lines.length > 5) {
91
- text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`;
91
+ text = `${lines.slice(0, 5).join("\n")}\n…`;
92
92
  }
93
93
  }
94
94
 
@@ -51,7 +51,7 @@ export class PythonExecutionComponent extends Container {
51
51
  ui,
52
52
  spinner => theme.fg(colorKey, spinner),
53
53
  text => theme.fg("muted", text),
54
- `Running${theme.format.ellipsis} (esc to cancel)`,
54
+ `Running (esc to cancel)`,
55
55
  getSymbolTheme().spinnerFrames,
56
56
  );
57
57
  this.contentContainer.addChild(this.loader);
@@ -136,9 +136,7 @@ export class PythonExecutionComponent extends Container {
136
136
  const statusParts: string[] = [];
137
137
 
138
138
  if (hiddenLineCount > 0) {
139
- statusParts.push(
140
- theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`),
141
- );
139
+ statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
142
140
  }
143
141
 
144
142
  if (this.status === "cancelled") {
@@ -105,7 +105,7 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
105
105
 
106
106
  private formatPath(entry: ReadEntry): string {
107
107
  const filePath = shortenPath(entry.path);
108
- let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", theme.format.ellipsis);
108
+ let pathDisplay = filePath ? theme.fg("accent", filePath) : theme.fg("toolOutput", "…");
109
109
  if (entry.offset !== undefined || entry.limit !== undefined) {
110
110
  const startLine = entry.offset ?? 1;
111
111
  const endLine = entry.limit !== undefined ? startLine + entry.limit - 1 : "";
@@ -74,15 +74,11 @@ class SessionList implements Component {
74
74
  if (this.filteredSessions.length === 0) {
75
75
  if (this.showCwd) {
76
76
  // "All" scope - no sessions anywhere that match filter
77
- lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width, theme.format.ellipsis));
77
+ lines.push(truncateToWidth(theme.fg("muted", " No sessions found"), width));
78
78
  } else {
79
79
  // "Current folder" scope - hint to try "all"
80
80
  lines.push(
81
- truncateToWidth(
82
- theme.fg("muted", " No sessions in current folder. Press Tab to view all."),
83
- width,
84
- theme.format.ellipsis,
85
- ),
81
+ truncateToWidth(theme.fg("muted", " No sessions in current folder. Press Tab to view all."), width),
86
82
  );
87
83
  }
88
84
  return lines;
@@ -128,16 +124,16 @@ class SessionList implements Component {
128
124
 
129
125
  if (session.title) {
130
126
  // Has title: show title on first line, dimmed first message on second line
131
- const truncatedTitle = truncateToWidth(session.title, maxWidth, theme.format.ellipsis);
127
+ const truncatedTitle = truncateToWidth(session.title, maxWidth);
132
128
  const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle);
133
129
  lines.push(titleLine);
134
130
 
135
131
  // Second line: dimmed first message preview
136
- const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis);
132
+ const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth);
137
133
  lines.push(` ${theme.fg("dim", truncatedPreview)}`);
138
134
  } else {
139
135
  // No title: show first message as main line
140
- const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis);
136
+ const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth);
141
137
  const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
142
138
  lines.push(messageLine);
143
139
  }
@@ -146,7 +142,7 @@ class SessionList implements Component {
146
142
  const modified = formatDate(session.modified);
147
143
  const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
148
144
  const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`;
149
- const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, theme.format.ellipsis));
145
+ const metadataLine = theme.fg("dim", truncateToWidth(metadata, width));
150
146
 
151
147
  lines.push(metadataLine);
152
148
  lines.push(""); // Blank line between sessions
@@ -155,7 +151,7 @@ class SessionList implements Component {
155
151
  // Add scroll indicator if needed
156
152
  if (startIndex > 0 || endIndex < this.filteredSessions.length) {
157
153
  const scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;
158
- const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width, theme.format.ellipsis));
154
+ const scrollInfo = theme.fg("muted", truncateToWidth(scrollText, width));
159
155
  lines.push(scrollInfo);
160
156
  }
161
157
 
@@ -102,7 +102,7 @@ const pathSegment: StatusLineSegment = {
102
102
 
103
103
  const maxLen = opts.maxLength ?? 40;
104
104
  if (pwd.length > maxLen) {
105
- const ellipsis = theme.format.ellipsis;
105
+ const ellipsis = "…";
106
106
  const sliceLen = Math.max(0, maxLen - ellipsis.length);
107
107
  pwd = `${ellipsis}${pwd.slice(-sliceLen)}`;
108
108
  }
@@ -423,6 +423,6 @@ export class StatusLineComponent implements Component {
423
423
  .sort(([a], [b]) => a.localeCompare(b))
424
424
  .map(([, text]) => sanitizeStatusText(text));
425
425
  const hookLine = sortedStatuses.join(" ");
426
- return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", theme.format.ellipsis))];
426
+ return [truncateToWidth(hookLine, width)];
427
427
  }
428
428
  }
@@ -54,7 +54,7 @@ function formatCompactValue(value: unknown, maxLength: number): string {
54
54
  }
55
55
 
56
56
  if (rendered.length > maxLength) {
57
- rendered = `${rendered.slice(0, maxLength - 1)}${theme.format.ellipsis}`;
57
+ rendered = `${rendered.slice(0, maxLength - 1)}…`;
58
58
  }
59
59
 
60
60
  return rendered;
@@ -630,7 +630,7 @@ export class ToolExecutionComponent extends Container {
630
630
  text += `\n${theme.fg("dim", "(none)")}`;
631
631
  }
632
632
  if (argsPreview.remaining > 0) {
633
- text += theme.fg("dim", `\n${theme.format.ellipsis} (${argsPreview.remaining} more args) (ctrl+o to expand)`);
633
+ text += theme.fg("dim", `\n (${argsPreview.remaining} more args) (ctrl+o to expand)`);
634
634
  }
635
635
 
636
636
  const output = this.getTextOutput().trim();
@@ -643,7 +643,7 @@ export class ToolExecutionComponent extends Container {
643
643
  text += ` ${theme.fg("dim", `(${lines.length} lines)`)}`;
644
644
  text += `\n${displayLines.map(line => theme.fg("toolOutput", line)).join("\n")}`;
645
645
  if (remaining > 0) {
646
- text += theme.fg("dim", `\n${theme.format.ellipsis} (${remaining} earlier lines) (ctrl+o to expand)`);
646
+ text += theme.fg("dim", `\n (${remaining} earlier lines) (ctrl+o to expand)`);
647
647
  }
648
648
  } else {
649
649
  text += ` ${theme.fg("dim", "(empty)")}`;
@@ -59,7 +59,7 @@ export class TtsrNotificationComponent extends Container {
59
59
  // Truncate to first 2 lines
60
60
  const lines = displayText.split("\n");
61
61
  if (lines.length > 2) {
62
- displayText = `${lines.slice(0, 2).join("\n")}${theme.format.ellipsis}`;
62
+ displayText = `${lines.slice(0, 2).join("\n")}…`;
63
63
  }
64
64
  }
65
65
 
@@ -165,7 +165,7 @@ export class WelcomeComponent implements Component {
165
165
  private centerText(text: string, width: number): string {
166
166
  const visLen = visibleWidth(text);
167
167
  if (visLen >= width) {
168
- return truncateToWidth(text, width, theme.format.ellipsis);
168
+ return truncateToWidth(text, width);
169
169
  }
170
170
  const leftPad = Math.floor((width - visLen) / 2);
171
171
  const rightPad = width - visLen - leftPad;
@@ -206,7 +206,7 @@ export class WelcomeComponent implements Component {
206
206
  private fitToWidth(str: string, width: number): string {
207
207
  const visLen = visibleWidth(str);
208
208
  if (visLen > width) {
209
- const ellipsis = theme.format.ellipsis;
209
+ const ellipsis = "…";
210
210
  const ellipsisWidth = visibleWidth(ellipsis);
211
211
  const maxWidth = Math.max(0, width - ellipsisWidth);
212
212
  let truncated = "";
@@ -66,7 +66,7 @@ export class EventController {
66
66
  this.ctx.ui,
67
67
  spinner => theme.fg("accent", spinner),
68
68
  text => theme.fg("muted", text),
69
- `Working${theme.format.ellipsis} (esc to interrupt)`,
69
+ `Working (esc to interrupt)`,
70
70
  getSymbolTheme().spinnerFrames,
71
71
  );
72
72
  this.ctx.statusContainer.addChild(this.ctx.loadingAnimation);
@@ -286,7 +286,7 @@ export class EventController {
286
286
  this.ctx.ui,
287
287
  spinner => theme.fg("accent", spinner),
288
288
  text => theme.fg("muted", text),
289
- `${reasonText}Auto-compacting${theme.format.ellipsis} (esc to cancel)`,
289
+ `${reasonText}Auto-compacting (esc to cancel)`,
290
290
  getSymbolTheme().spinnerFrames,
291
291
  );
292
292
  this.ctx.statusContainer.addChild(this.ctx.autoCompactionLoader);
@@ -337,7 +337,7 @@ export class EventController {
337
337
  this.ctx.ui,
338
338
  spinner => theme.fg("warning", spinner),
339
339
  text => theme.fg("muted", text),
340
- `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s${theme.format.ellipsis} (esc to cancel)`,
340
+ `Retrying (${event.attempt}/${event.maxAttempts}) in ${delaySeconds}s (esc to cancel)`,
341
341
  getSymbolTheme().spinnerFrames,
342
342
  );
343
343
  this.ctx.statusContainer.addChild(this.ctx.retryLoader);
@@ -110,7 +110,7 @@ export class InteractiveMode implements InteractiveModeContext {
110
110
  public autoCompactionLoader: Loader | undefined = undefined;
111
111
  public retryLoader: Loader | undefined = undefined;
112
112
  private pendingWorkingMessage: string | undefined;
113
- private readonly defaultWorkingMessage = `Working${theme.format.ellipsis} (esc to interrupt)`;
113
+ private readonly defaultWorkingMessage = `Working (esc to interrupt)`;
114
114
  public autoCompactionEscapeHandler?: () => void;
115
115
  public retryEscapeHandler?: () => void;
116
116
  public unsubscribe?: () => void;