@oh-my-pi/pi-coding-agent 3.14.0 → 3.15.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 (148) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/docs/theme.md +38 -5
  3. package/examples/sdk/11-sessions.ts +2 -2
  4. package/package.json +7 -4
  5. package/src/cli/file-processor.ts +51 -2
  6. package/src/cli/plugin-cli.ts +25 -19
  7. package/src/cli/update-cli.ts +4 -3
  8. package/src/core/agent-session.ts +31 -4
  9. package/src/core/compaction/branch-summarization.ts +4 -32
  10. package/src/core/compaction/compaction.ts +6 -84
  11. package/src/core/compaction/utils.ts +2 -3
  12. package/src/core/custom-tools/types.ts +2 -0
  13. package/src/core/export-html/index.ts +1 -1
  14. package/src/core/hooks/tool-wrapper.ts +0 -1
  15. package/src/core/hooks/types.ts +2 -2
  16. package/src/core/plugins/doctor.ts +9 -1
  17. package/src/core/sdk.ts +2 -1
  18. package/src/core/session-manager.ts +518 -40
  19. package/src/core/settings-manager.ts +174 -0
  20. package/src/core/system-prompt.ts +9 -14
  21. package/src/core/title-generator.ts +2 -8
  22. package/src/core/tools/ask.ts +19 -37
  23. package/src/core/tools/bash.ts +2 -37
  24. package/src/core/tools/edit.ts +2 -9
  25. package/src/core/tools/exa/render.ts +52 -48
  26. package/src/core/tools/find.ts +10 -8
  27. package/src/core/tools/grep.ts +45 -17
  28. package/src/core/tools/ls.ts +22 -2
  29. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  30. package/src/core/tools/lsp/clients/index.ts +49 -0
  31. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  32. package/src/core/tools/lsp/config.ts +3 -0
  33. package/src/core/tools/lsp/index.ts +107 -55
  34. package/src/core/tools/lsp/render.ts +192 -79
  35. package/src/core/tools/lsp/types.ts +27 -0
  36. package/src/core/tools/lsp/utils.ts +62 -22
  37. package/src/core/tools/notebook.ts +9 -1
  38. package/src/core/tools/output.ts +37 -14
  39. package/src/core/tools/read.ts +349 -34
  40. package/src/core/tools/renderers.ts +290 -89
  41. package/src/core/tools/review.ts +12 -5
  42. package/src/core/tools/task/agents.ts +5 -5
  43. package/src/core/tools/task/commands.ts +3 -3
  44. package/src/core/tools/task/executor.ts +33 -1
  45. package/src/core/tools/task/index.ts +93 -6
  46. package/src/core/tools/task/render.ts +147 -66
  47. package/src/core/tools/task/types.ts +14 -9
  48. package/src/core/tools/web-fetch.ts +242 -103
  49. package/src/core/tools/web-search/index.ts +64 -20
  50. package/src/core/tools/web-search/providers/exa.ts +68 -172
  51. package/src/core/tools/web-search/render.ts +264 -74
  52. package/src/core/tools/write.ts +2 -8
  53. package/src/main.ts +10 -6
  54. package/src/modes/cleanup.ts +23 -0
  55. package/src/modes/index.ts +9 -4
  56. package/src/modes/interactive/components/bash-execution.ts +6 -3
  57. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  58. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  60. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  61. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  62. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  63. package/src/modes/interactive/components/hook-message.ts +2 -2
  64. package/src/modes/interactive/components/hook-selector.ts +1 -1
  65. package/src/modes/interactive/components/model-selector.ts +22 -9
  66. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  67. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  68. package/src/modes/interactive/components/session-selector.ts +9 -6
  69. package/src/modes/interactive/components/settings-defs.ts +285 -1
  70. package/src/modes/interactive/components/settings-selector.ts +176 -3
  71. package/src/modes/interactive/components/status-line/index.ts +4 -0
  72. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  73. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  74. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  75. package/src/modes/interactive/components/status-line/types.ts +81 -0
  76. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  77. package/src/modes/interactive/components/status-line.ts +170 -223
  78. package/src/modes/interactive/components/tool-execution.ts +446 -211
  79. package/src/modes/interactive/components/tree-selector.ts +17 -6
  80. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  81. package/src/modes/interactive/components/welcome.ts +27 -19
  82. package/src/modes/interactive/interactive-mode.ts +98 -13
  83. package/src/modes/interactive/theme/dark.json +3 -2
  84. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  85. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  86. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  87. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  88. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  89. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  90. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  91. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  92. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  93. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  94. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  95. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  96. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  97. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  98. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  99. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  100. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  101. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  102. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  103. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  104. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  105. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  106. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  107. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  108. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  109. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  110. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  111. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  112. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  114. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  115. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  116. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  117. package/src/modes/interactive/theme/light.json +3 -2
  118. package/src/modes/interactive/theme/theme-schema.json +120 -4
  119. package/src/modes/interactive/theme/theme.ts +1228 -14
  120. package/src/prompts/branch-summary-preamble.md +3 -0
  121. package/src/prompts/branch-summary.md +28 -0
  122. package/src/prompts/compaction-summary.md +34 -0
  123. package/src/prompts/compaction-turn-prefix.md +16 -0
  124. package/src/prompts/compaction-update-summary.md +41 -0
  125. package/src/prompts/init.md +30 -0
  126. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  127. package/src/prompts/summarization-system.md +3 -0
  128. package/src/prompts/system-prompt.md +27 -0
  129. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  130. package/src/prompts/title-system.md +8 -0
  131. package/src/prompts/tools/ask.md +24 -0
  132. package/src/prompts/tools/bash.md +23 -0
  133. package/src/prompts/tools/edit.md +9 -0
  134. package/src/prompts/tools/find.md +6 -0
  135. package/src/prompts/tools/grep.md +12 -0
  136. package/src/prompts/tools/lsp.md +14 -0
  137. package/src/prompts/tools/output.md +23 -0
  138. package/src/prompts/tools/read.md +25 -0
  139. package/src/prompts/tools/web-fetch.md +8 -0
  140. package/src/prompts/tools/web-search.md +10 -0
  141. package/src/prompts/tools/write.md +10 -0
  142. package/src/commands/init.md +0 -20
  143. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  144. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  146. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  148. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -152,8 +152,10 @@ export class ExtensionList implements Component {
152
152
  }
153
153
 
154
154
  private renderMasterSwitch(item: ListItem & { type: "master" }, isSelected: boolean, width: number): string {
155
- const checkbox = item.enabled ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
156
- const icon = "📦";
155
+ const checkbox = item.enabled
156
+ ? theme.fg("success", theme.checkbox.checked)
157
+ : theme.fg("dim", theme.checkbox.unchecked);
158
+ const icon = theme.icon.package;
157
159
  const label = `Enable ${item.providerName}`;
158
160
  const badge = theme.fg("warning", "(Master Switch)");
159
161
 
@@ -229,39 +231,39 @@ export class ExtensionList implements Component {
229
231
  private getKindIcon(kind: ExtensionKind): string {
230
232
  switch (kind) {
231
233
  case "skill":
232
- return "⚡";
234
+ return theme.icon.extensionSkill;
233
235
  case "tool":
234
- return "🔧";
236
+ return theme.icon.extensionTool;
235
237
  case "slash-command":
236
- return "🔗";
238
+ return theme.icon.extensionSlashCommand;
237
239
  case "mcp":
238
- return "🔄";
240
+ return theme.icon.extensionMcp;
239
241
  case "rule":
240
- return "📋";
242
+ return theme.icon.extensionRule;
241
243
  case "hook":
242
- return "🪝";
244
+ return theme.icon.extensionHook;
243
245
  case "prompt":
244
- return "💬";
246
+ return theme.icon.extensionPrompt;
245
247
  case "context-file":
246
- return "📄";
248
+ return theme.icon.extensionContextFile;
247
249
  case "instruction":
248
- return "📌";
250
+ return theme.icon.extensionInstruction;
249
251
  default:
250
- return "•";
252
+ return theme.format.bullet;
251
253
  }
252
254
  }
253
255
 
254
256
  private getStateIcon(state: ExtensionState, masterDisabled: boolean): string {
255
257
  if (masterDisabled) {
256
- return theme.fg("dim", "○");
258
+ return theme.fg("dim", theme.status.disabled);
257
259
  }
258
260
  switch (state) {
259
261
  case "active":
260
- return theme.fg("success", "●");
262
+ return theme.fg("success", theme.status.enabled);
261
263
  case "disabled":
262
- return theme.fg("dim", "⊘");
264
+ return theme.fg("dim", theme.status.disabled);
263
265
  case "shadowed":
264
- return theme.fg("warning", "◐");
266
+ return theme.fg("warning", theme.status.shadowed);
265
267
  }
266
268
  }
267
269
 
@@ -95,7 +95,7 @@ export class InspectorPanel implements Component {
95
95
  private renderFilePreview(path: string, width: number): string[] {
96
96
  const lines: string[] = [];
97
97
  lines.push(theme.fg("muted", "Preview:"));
98
- lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
98
+ lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
99
99
 
100
100
  try {
101
101
  const content = readFileSync(path, "utf-8");
@@ -144,7 +144,7 @@ export class InspectorPanel implements Component {
144
144
  private renderToolArgs(raw: unknown, width: number): string[] {
145
145
  const lines: string[] = [];
146
146
  lines.push(theme.fg("muted", "Arguments:"));
147
- lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
147
+ lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
148
148
 
149
149
  try {
150
150
  const tool = raw as any;
@@ -183,7 +183,7 @@ export class InspectorPanel implements Component {
183
183
  private renderSkillContent(raw: unknown, width: number): string[] {
184
184
  const lines: string[] = [];
185
185
  lines.push(theme.fg("muted", "Instruction:"));
186
- lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
186
+ lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
187
187
 
188
188
  try {
189
189
  const skill = raw as any;
@@ -212,7 +212,7 @@ export class InspectorPanel implements Component {
212
212
  private renderMcpDetails(raw: unknown, width: number): string[] {
213
213
  const lines: string[] = [];
214
214
  lines.push(theme.fg("muted", "Connection:"));
215
- lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
215
+ lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
216
216
 
217
217
  try {
218
218
  const mcp = raw as any;
@@ -251,7 +251,7 @@ export class InspectorPanel implements Component {
251
251
  // Show trigger pattern if present
252
252
  if (ext.trigger) {
253
253
  lines.push(theme.fg("muted", "Trigger:"));
254
- lines.push(theme.fg("dim", "─".repeat(Math.min(width - 2, 40))));
254
+ lines.push(theme.fg("dim", theme.boxSharp.horizontal.repeat(Math.min(width - 2, 40))));
255
255
  lines.push(` ${theme.fg("accent", ext.trigger)}`);
256
256
  lines.push("");
257
257
  }
@@ -279,7 +279,7 @@ export class InspectorPanel implements Component {
279
279
  private getStatusBadge(state: ExtensionState, reason?: string, shadowedBy?: string): string {
280
280
  switch (state) {
281
281
  case "active":
282
- return theme.fg("success", "● Active");
282
+ return theme.fg("success", `${theme.status.enabled} Active`);
283
283
  case "disabled": {
284
284
  const reasonText =
285
285
  reason === "provider-disabled"
@@ -287,10 +287,10 @@ export class InspectorPanel implements Component {
287
287
  : reason === "item-disabled"
288
288
  ? "manually disabled"
289
289
  : "unknown";
290
- return theme.fg("dim", `○ Disabled (${reasonText})`);
290
+ return theme.fg("dim", `${theme.status.disabled} Disabled (${reasonText})`);
291
291
  }
292
292
  case "shadowed":
293
- return theme.fg("warning", `◐ Shadowed${shadowedBy ? ` by ${shadowedBy}` : ""}`);
293
+ return theme.fg("warning", `${theme.status.shadowed} Shadowed${shadowedBy ? ` by ${shadowedBy}` : ""}`);
294
294
  }
295
295
  }
296
296
 
@@ -64,7 +64,7 @@ export class HookMessageComponent extends Container {
64
64
  this.box.clear();
65
65
 
66
66
  // Default rendering: label + content
67
- const label = theme.fg("customMessageLabel", `\x1b[1m[${this.message.customType}]\x1b[22m`);
67
+ const label = theme.fg("customMessageLabel", theme.bold(`[${this.message.customType}]`));
68
68
  this.box.addChild(new Text(label, 0, 0));
69
69
  this.box.addChild(new Spacer(1));
70
70
 
@@ -83,7 +83,7 @@ export class HookMessageComponent extends Container {
83
83
  if (!this._expanded) {
84
84
  const lines = text.split("\n");
85
85
  if (lines.length > 5) {
86
- text = `${lines.slice(0, 5).join("\n")}\n...`;
86
+ text = `${lines.slice(0, 5).join("\n")}\n${theme.format.ellipsis}`;
87
87
  }
88
88
  }
89
89
 
@@ -56,7 +56,7 @@ export class HookSelectorComponent extends Container {
56
56
 
57
57
  let text = "";
58
58
  if (isSelected) {
59
- text = theme.fg("accent", "→ ") + theme.fg("accent", option);
59
+ text = theme.fg("accent", `${theme.nav.cursor} `) + theme.fg("accent", option);
60
60
  } else {
61
61
  text = ` ${theme.fg("text", option)}`;
62
62
  }
@@ -13,6 +13,7 @@ import {
13
13
  Spacer,
14
14
  Text,
15
15
  type TUI,
16
+ visibleWidth,
16
17
  } from "@oh-my-pi/pi-tui";
17
18
  import type { ModelRegistry } from "../../../core/model-registry";
18
19
  import { parseModelString } from "../../../core/model-resolver";
@@ -276,7 +277,7 @@ export class ModelSelectorComponent extends Container {
276
277
  }
277
278
 
278
279
  parts.push(" ");
279
- parts.push(theme.fg("dim", "(←/→ or Tab to switch)"));
280
+ parts.push(theme.fg("dim", `(${theme.nav.back}/${theme.nav.cursor} or Tab to switch)`));
280
281
 
281
282
  this.headerContainer.addChild(new Text(parts.join(""), 0, 0));
282
283
  }
@@ -341,14 +342,14 @@ export class ModelSelectorComponent extends Container {
341
342
 
342
343
  // Build role badges (right-aligned style)
343
344
  const badges: string[] = [];
344
- if (isDefault) badges.push(theme.fg("success", "[ DEFAULT ]"));
345
- if (isSmol) badges.push(theme.fg("warning", "[ SMOL ]"));
346
- if (isSlow) badges.push(theme.fg("accent", "[ SLOW ]"));
345
+ if (isDefault) badges.push(theme.fg("success", `${theme.sep.pipe}DEFAULT${theme.sep.pipe}`));
346
+ if (isSmol) badges.push(theme.fg("warning", `${theme.sep.pipe}SMOL${theme.sep.pipe}`));
347
+ if (isSlow) badges.push(theme.fg("accent", `${theme.sep.pipe}SLOW${theme.sep.pipe}`));
347
348
  const badgeText = badges.length > 0 ? ` ${badges.join(" ")}` : "";
348
349
 
349
350
  let line = "";
350
351
  if (isSelected) {
351
- const prefix = theme.fg("accent", "→ ");
352
+ const prefix = theme.fg("accent", `${theme.nav.cursor} `);
352
353
  const modelText = item.id;
353
354
  if (showProvider) {
354
355
  const providerBadge = theme.fg("muted", `[${item.provider}]`);
@@ -405,9 +406,21 @@ export class ModelSelectorComponent extends Container {
405
406
  const selectedModel = this.filteredModels[this.selectedIndex];
406
407
  if (!selectedModel) return;
407
408
 
409
+ const headerText = ` Action for: ${selectedModel.id}`;
410
+ const hintText = " Enter: confirm Esc: cancel";
411
+ const actionLines = MENU_ACTIONS.map((action, index) => {
412
+ const prefix = index === this.menuSelectedIndex ? ` ${theme.nav.cursor} ` : " ";
413
+ return `${prefix}${action.label}`;
414
+ });
415
+ const menuWidth = Math.max(
416
+ visibleWidth(headerText),
417
+ visibleWidth(hintText),
418
+ ...actionLines.map((line) => visibleWidth(line)),
419
+ );
420
+
408
421
  // Menu header
409
422
  this.menuContainer.addChild(new Spacer(1));
410
- this.menuContainer.addChild(new Text(theme.fg("border", "─".repeat(40)), 0, 0));
423
+ this.menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
411
424
  this.menuContainer.addChild(new Text(theme.fg("text", ` Action for: ${theme.bold(selectedModel.id)}`), 0, 0));
412
425
  this.menuContainer.addChild(new Spacer(1));
413
426
 
@@ -418,7 +431,7 @@ export class ModelSelectorComponent extends Container {
418
431
 
419
432
  let line: string;
420
433
  if (isSelected) {
421
- line = theme.fg("accent", ` ${action.label}`);
434
+ line = theme.fg("accent", ` ${theme.nav.cursor} ${action.label}`);
422
435
  } else {
423
436
  line = theme.fg("muted", ` ${action.label}`);
424
437
  }
@@ -426,8 +439,8 @@ export class ModelSelectorComponent extends Container {
426
439
  }
427
440
 
428
441
  this.menuContainer.addChild(new Spacer(1));
429
- this.menuContainer.addChild(new Text(theme.fg("dim", " Enter: confirm Esc: cancel"), 0, 0));
430
- this.menuContainer.addChild(new Text(theme.fg("border", "─".repeat(40)), 0, 0));
442
+ this.menuContainer.addChild(new Text(theme.fg("dim", hintText), 0, 0));
443
+ this.menuContainer.addChild(new Text(theme.fg("border", theme.boxSharp.horizontal.repeat(menuWidth)), 0, 0));
431
444
  }
432
445
 
433
446
  handleInput(keyData: string): void {
@@ -15,6 +15,7 @@ export class OAuthSelectorComponent extends Container {
15
15
  private authStorage: AuthStorage;
16
16
  private onSelectCallback: (providerId: string) => void;
17
17
  private onCancelCallback: () => void;
18
+ private statusMessage: string | undefined;
18
19
 
19
20
  constructor(
20
21
  mode: "login" | "logout",
@@ -71,11 +72,11 @@ export class OAuthSelectorComponent extends Container {
71
72
  // Check if user is logged in for this provider
72
73
  const credentials = this.authStorage.get(provider.id);
73
74
  const isLoggedIn = credentials?.type === "oauth";
74
- const statusIndicator = isLoggedIn ? theme.fg("success", " logged in") : "";
75
+ const statusIndicator = isLoggedIn ? theme.fg("success", ` ${theme.status.success} logged in`) : "";
75
76
 
76
77
  let line = "";
77
78
  if (isSelected) {
78
- const prefix = theme.fg("accent", "→ ");
79
+ const prefix = theme.fg("accent", `${theme.nav.cursor} `);
79
80
  const text = isAvailable ? theme.fg("accent", provider.name) : theme.fg("dim", provider.name);
80
81
  line = prefix + text + statusIndicator;
81
82
  } else {
@@ -92,24 +93,39 @@ export class OAuthSelectorComponent extends Container {
92
93
  this.mode === "login" ? "No OAuth providers available" : "No OAuth providers logged in. Use /login first.";
93
94
  this.listContainer.addChild(new TruncatedText(theme.fg("muted", ` ${message}`), 0, 0));
94
95
  }
96
+
97
+ if (this.statusMessage) {
98
+ this.listContainer.addChild(new Spacer(1));
99
+ this.listContainer.addChild(new TruncatedText(theme.fg("warning", ` ${this.statusMessage}`), 0, 0));
100
+ }
95
101
  }
96
102
 
97
103
  handleInput(keyData: string): void {
98
104
  // Up arrow
99
105
  if (isArrowUp(keyData)) {
100
- this.selectedIndex = Math.max(0, this.selectedIndex - 1);
106
+ if (this.allProviders.length > 0) {
107
+ this.selectedIndex = this.selectedIndex === 0 ? this.allProviders.length - 1 : this.selectedIndex - 1;
108
+ }
109
+ this.statusMessage = undefined;
101
110
  this.updateList();
102
111
  }
103
112
  // Down arrow
104
113
  else if (isArrowDown(keyData)) {
105
- this.selectedIndex = Math.min(this.allProviders.length - 1, this.selectedIndex + 1);
114
+ if (this.allProviders.length > 0) {
115
+ this.selectedIndex = this.selectedIndex === this.allProviders.length - 1 ? 0 : this.selectedIndex + 1;
116
+ }
117
+ this.statusMessage = undefined;
106
118
  this.updateList();
107
119
  }
108
120
  // Enter
109
121
  else if (isEnter(keyData)) {
110
122
  const selectedProvider = this.allProviders[this.selectedIndex];
111
123
  if (selectedProvider?.available) {
124
+ this.statusMessage = undefined;
112
125
  this.onSelectCallback(selectedProvider.id);
126
+ } else if (selectedProvider) {
127
+ this.statusMessage = "Provider unavailable in this environment.";
128
+ this.updateList();
113
129
  }
114
130
  }
115
131
  // Escape
@@ -63,13 +63,15 @@ export class PluginListComponent extends Container {
63
63
  }
64
64
 
65
65
  const items: SelectItem[] = plugins.map((p) => {
66
- const status = p.enabled ? theme.fg("success", "●") : theme.fg("muted", "○");
66
+ const status = p.enabled
67
+ ? theme.fg("success", theme.status.enabled)
68
+ : theme.fg("muted", theme.status.disabled);
67
69
  const featureCount = p.manifest.features ? Object.keys(p.manifest.features).length : 0;
68
70
  const enabledCount = p.enabledFeatures?.length ?? featureCount;
69
71
 
70
72
  let details = `v${p.version}`;
71
73
  if (featureCount > 0) {
72
- details += ` · ${enabledCount}/${featureCount} features`;
74
+ details += ` ${theme.sep.dot} ${enabledCount}/${featureCount} features`;
73
75
  }
74
76
 
75
77
  return {
@@ -10,6 +10,7 @@ import {
10
10
  Spacer,
11
11
  Text,
12
12
  truncateToWidth,
13
+ visibleWidth,
13
14
  } from "@oh-my-pi/pi-tui";
14
15
  import type { SessionInfo } from "../../../core/session-manager";
15
16
  import { fuzzyFilter } from "../../../utils/fuzzy";
@@ -99,21 +100,23 @@ class SessionList implements Component {
99
100
  const normalizedMessage = session.firstMessage.replace(/\n/g, " ").trim();
100
101
 
101
102
  // First line: cursor + title (or first message if no title)
102
- const cursor = isSelected ? theme.fg("accent", "› ") : " ";
103
- const maxWidth = width - 2; // Account for cursor (2 visible chars)
103
+ const cursorSymbol = `${theme.nav.cursor} `;
104
+ const cursorWidth = visibleWidth(cursorSymbol);
105
+ const cursor = isSelected ? theme.fg("accent", cursorSymbol) : " ".repeat(cursorWidth);
106
+ const maxWidth = width - cursorWidth; // Account for cursor width
104
107
 
105
108
  if (session.title) {
106
109
  // Has title: show title on first line, dimmed first message on second line
107
- const truncatedTitle = truncateToWidth(session.title, maxWidth, "...");
110
+ const truncatedTitle = truncateToWidth(session.title, maxWidth, theme.format.ellipsis);
108
111
  const titleLine = cursor + (isSelected ? theme.bold(truncatedTitle) : truncatedTitle);
109
112
  lines.push(titleLine);
110
113
 
111
114
  // Second line: dimmed first message preview
112
- const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, "...");
115
+ const truncatedPreview = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis);
113
116
  lines.push(` ${theme.fg("dim", truncatedPreview)}`);
114
117
  } else {
115
118
  // No title: show first message as main line
116
- const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, "...");
119
+ const truncatedMsg = truncateToWidth(normalizedMessage, maxWidth, theme.format.ellipsis);
117
120
  const messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);
118
121
  lines.push(messageLine);
119
122
  }
@@ -121,7 +124,7 @@ class SessionList implements Component {
121
124
  // Metadata line: date + message count
122
125
  const modified = formatDate(session.modified);
123
126
  const msgCount = `${session.messageCount} message${session.messageCount !== 1 ? "s" : ""}`;
124
- const metadata = ` ${modified} · ${msgCount}`;
127
+ const metadata = ` ${modified} ${theme.sep.dot} ${msgCount}`;
125
128
  const metadataLine = theme.fg("dim", truncateToWidth(metadata, width, ""));
126
129
 
127
130
  lines.push(metadataLine);
@@ -10,7 +10,13 @@
10
10
 
11
11
  import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
12
12
  import { getCapabilities } from "@oh-my-pi/pi-tui";
13
- import type { SettingsManager } from "../../../core/settings-manager";
13
+ import type {
14
+ SettingsManager,
15
+ StatusLinePreset,
16
+ StatusLineSeparatorStyle,
17
+ SymbolPreset,
18
+ } from "../../../core/settings-manager";
19
+ import { getPreset } from "./status-line/presets";
14
20
 
15
21
  // Setting value types
16
22
  export type SettingValue = boolean | string;
@@ -208,6 +214,20 @@ export const SETTINGS_DEFS: SettingDef[] = [
208
214
  set: (sm, v) => sm.setTheme(v),
209
215
  getOptions: () => [], // Filled dynamically from context
210
216
  },
217
+ {
218
+ id: "symbolPreset",
219
+ tab: "config",
220
+ type: "submenu",
221
+ label: "Symbol preset",
222
+ description: "Icon/symbol style (overrides theme default)",
223
+ get: (sm) => sm.getSymbolPreset() ?? "unicode",
224
+ set: (sm, v) => sm.setSymbolPreset(v as SymbolPreset),
225
+ getOptions: () => [
226
+ { value: "unicode", label: "Unicode", description: "Standard Unicode symbols (default)" },
227
+ { value: "nerd", label: "Nerd Font", description: "Nerd Font icons (requires Nerd Font)" },
228
+ { value: "ascii", label: "ASCII", description: "ASCII-only characters (maximum compatibility)" },
229
+ ],
230
+ },
211
231
 
212
232
  // LSP tab
213
233
  {
@@ -293,6 +313,270 @@ export const SETTINGS_DEFS: SettingDef[] = [
293
313
  get: (sm) => sm.getExaSettings().enableWebsets,
294
314
  set: (sm, v) => sm.setExaWebsetsEnabled(v),
295
315
  },
316
+
317
+ // Status Line tab
318
+ {
319
+ id: "statusLinePreset",
320
+ tab: "status",
321
+ type: "submenu",
322
+ label: "Preset",
323
+ description: "Pre-built status line configurations",
324
+ get: (sm) => sm.getStatusLinePreset(),
325
+ set: (sm, v) => sm.setStatusLinePreset(v as StatusLinePreset),
326
+ getOptions: () => [
327
+ { value: "default", label: "Default", description: "Model, path, git, context, tokens, cost" },
328
+ { value: "minimal", label: "Minimal", description: "Path and git only" },
329
+ { value: "compact", label: "Compact", description: "Model, git, cost, context" },
330
+ { value: "full", label: "Full", description: "All segments including time" },
331
+ { value: "nerd", label: "Nerd", description: "Maximum info with Nerd Font icons" },
332
+ { value: "ascii", label: "ASCII", description: "No special characters" },
333
+ { value: "custom", label: "Custom", description: "User-defined segments" },
334
+ ],
335
+ },
336
+ {
337
+ id: "statusLineSeparator",
338
+ tab: "status",
339
+ type: "submenu",
340
+ label: "Separator style",
341
+ description: "Style of separators between segments",
342
+ get: (sm) => {
343
+ const settings = sm.getStatusLineSettings();
344
+ if (settings.separator) return settings.separator;
345
+ return getPreset(sm.getStatusLinePreset()).separator;
346
+ },
347
+ set: (sm, v) => sm.setStatusLineSeparator(v as StatusLineSeparatorStyle),
348
+ getOptions: () => [
349
+ { value: "powerline", label: "Powerline", description: "Solid arrows (requires Nerd Font)" },
350
+ { value: "powerline-thin", label: "Thin chevron", description: "Thin arrows (requires Nerd Font)" },
351
+ { value: "slash", label: "Slash", description: "Forward slashes" },
352
+ { value: "pipe", label: "Pipe", description: "Vertical pipes" },
353
+ { value: "block", label: "Block", description: "Solid blocks" },
354
+ { value: "none", label: "None", description: "Space only" },
355
+ { value: "ascii", label: "ASCII", description: "Greater-than signs" },
356
+ ],
357
+ },
358
+ {
359
+ id: "statusLineShowHooks",
360
+ tab: "status",
361
+ type: "boolean",
362
+ label: "Show hook status",
363
+ description: "Display hook status messages below status line",
364
+ get: (sm) => sm.getStatusLineShowHookStatus(),
365
+ set: (sm, v) => sm.setStatusLineShowHookStatus(v),
366
+ },
367
+ {
368
+ id: "statusLineSegments",
369
+ tab: "status",
370
+ type: "submenu",
371
+ label: "Configure segments",
372
+ description: "Choose and arrange status line segments",
373
+ get: () => "configure...",
374
+ set: () => {}, // Handled specially
375
+ getOptions: () => [{ value: "open", label: "Open segment editor..." }],
376
+ },
377
+ {
378
+ id: "statusLineModelThinking",
379
+ tab: "status",
380
+ type: "enum",
381
+ label: "Model thinking level",
382
+ description: "Show thinking level in the model segment",
383
+ values: ["default", "on", "off"],
384
+ get: (sm) => {
385
+ const value = sm.getStatusLineSegmentOptions().model?.showThinkingLevel;
386
+ if (value === undefined) return "default";
387
+ return value ? "on" : "off";
388
+ },
389
+ set: (sm, v) => {
390
+ if (v === "default") {
391
+ sm.clearStatusLineSegmentOption("model", "showThinkingLevel");
392
+ } else {
393
+ sm.setStatusLineSegmentOption("model", "showThinkingLevel", v === "on");
394
+ }
395
+ },
396
+ },
397
+ {
398
+ id: "statusLinePathAbbreviate",
399
+ tab: "status",
400
+ type: "enum",
401
+ label: "Path abbreviate",
402
+ description: "Use ~ and strip home prefix in path segment",
403
+ values: ["default", "on", "off"],
404
+ get: (sm) => {
405
+ const value = sm.getStatusLineSegmentOptions().path?.abbreviate;
406
+ if (value === undefined) return "default";
407
+ return value ? "on" : "off";
408
+ },
409
+ set: (sm, v) => {
410
+ if (v === "default") {
411
+ sm.clearStatusLineSegmentOption("path", "abbreviate");
412
+ } else {
413
+ sm.setStatusLineSegmentOption("path", "abbreviate", v === "on");
414
+ }
415
+ },
416
+ },
417
+ {
418
+ id: "statusLinePathMaxLength",
419
+ tab: "status",
420
+ type: "submenu",
421
+ label: "Path max length",
422
+ description: "Maximum length for displayed path",
423
+ get: (sm) => {
424
+ const value = sm.getStatusLineSegmentOptions().path?.maxLength;
425
+ return typeof value === "number" ? String(value) : "default";
426
+ },
427
+ set: (sm, v) => {
428
+ if (v === "default") {
429
+ sm.clearStatusLineSegmentOption("path", "maxLength");
430
+ } else {
431
+ sm.setStatusLineSegmentOption("path", "maxLength", Number.parseInt(v, 10));
432
+ }
433
+ },
434
+ getOptions: () => [
435
+ { value: "default", label: "Preset default" },
436
+ { value: "20", label: "20" },
437
+ { value: "30", label: "30" },
438
+ { value: "40", label: "40" },
439
+ { value: "50", label: "50" },
440
+ { value: "60", label: "60" },
441
+ { value: "80", label: "80" },
442
+ ],
443
+ },
444
+ {
445
+ id: "statusLinePathStripWorkPrefix",
446
+ tab: "status",
447
+ type: "enum",
448
+ label: "Path strip /work",
449
+ description: "Strip /work prefix in path segment",
450
+ values: ["default", "on", "off"],
451
+ get: (sm) => {
452
+ const value = sm.getStatusLineSegmentOptions().path?.stripWorkPrefix;
453
+ if (value === undefined) return "default";
454
+ return value ? "on" : "off";
455
+ },
456
+ set: (sm, v) => {
457
+ if (v === "default") {
458
+ sm.clearStatusLineSegmentOption("path", "stripWorkPrefix");
459
+ } else {
460
+ sm.setStatusLineSegmentOption("path", "stripWorkPrefix", v === "on");
461
+ }
462
+ },
463
+ },
464
+ {
465
+ id: "statusLineGitShowBranch",
466
+ tab: "status",
467
+ type: "enum",
468
+ label: "Git show branch",
469
+ description: "Show branch name in git segment",
470
+ values: ["default", "on", "off"],
471
+ get: (sm) => {
472
+ const value = sm.getStatusLineSegmentOptions().git?.showBranch;
473
+ if (value === undefined) return "default";
474
+ return value ? "on" : "off";
475
+ },
476
+ set: (sm, v) => {
477
+ if (v === "default") {
478
+ sm.clearStatusLineSegmentOption("git", "showBranch");
479
+ } else {
480
+ sm.setStatusLineSegmentOption("git", "showBranch", v === "on");
481
+ }
482
+ },
483
+ },
484
+ {
485
+ id: "statusLineGitShowStaged",
486
+ tab: "status",
487
+ type: "enum",
488
+ label: "Git show staged",
489
+ description: "Show staged file count in git segment",
490
+ values: ["default", "on", "off"],
491
+ get: (sm) => {
492
+ const value = sm.getStatusLineSegmentOptions().git?.showStaged;
493
+ if (value === undefined) return "default";
494
+ return value ? "on" : "off";
495
+ },
496
+ set: (sm, v) => {
497
+ if (v === "default") {
498
+ sm.clearStatusLineSegmentOption("git", "showStaged");
499
+ } else {
500
+ sm.setStatusLineSegmentOption("git", "showStaged", v === "on");
501
+ }
502
+ },
503
+ },
504
+ {
505
+ id: "statusLineGitShowUnstaged",
506
+ tab: "status",
507
+ type: "enum",
508
+ label: "Git show unstaged",
509
+ description: "Show unstaged file count in git segment",
510
+ values: ["default", "on", "off"],
511
+ get: (sm) => {
512
+ const value = sm.getStatusLineSegmentOptions().git?.showUnstaged;
513
+ if (value === undefined) return "default";
514
+ return value ? "on" : "off";
515
+ },
516
+ set: (sm, v) => {
517
+ if (v === "default") {
518
+ sm.clearStatusLineSegmentOption("git", "showUnstaged");
519
+ } else {
520
+ sm.setStatusLineSegmentOption("git", "showUnstaged", v === "on");
521
+ }
522
+ },
523
+ },
524
+ {
525
+ id: "statusLineGitShowUntracked",
526
+ tab: "status",
527
+ type: "enum",
528
+ label: "Git show untracked",
529
+ description: "Show untracked file count in git segment",
530
+ values: ["default", "on", "off"],
531
+ get: (sm) => {
532
+ const value = sm.getStatusLineSegmentOptions().git?.showUntracked;
533
+ if (value === undefined) return "default";
534
+ return value ? "on" : "off";
535
+ },
536
+ set: (sm, v) => {
537
+ if (v === "default") {
538
+ sm.clearStatusLineSegmentOption("git", "showUntracked");
539
+ } else {
540
+ sm.setStatusLineSegmentOption("git", "showUntracked", v === "on");
541
+ }
542
+ },
543
+ },
544
+ {
545
+ id: "statusLineTimeFormat",
546
+ tab: "status",
547
+ type: "enum",
548
+ label: "Time format",
549
+ description: "Clock segment time format",
550
+ values: ["default", "12h", "24h"],
551
+ get: (sm) => sm.getStatusLineSegmentOptions().time?.format ?? "default",
552
+ set: (sm, v) => {
553
+ if (v === "default") {
554
+ sm.clearStatusLineSegmentOption("time", "format");
555
+ } else {
556
+ sm.setStatusLineSegmentOption("time", "format", v);
557
+ }
558
+ },
559
+ },
560
+ {
561
+ id: "statusLineTimeShowSeconds",
562
+ tab: "status",
563
+ type: "enum",
564
+ label: "Time show seconds",
565
+ description: "Include seconds in clock segment",
566
+ values: ["default", "on", "off"],
567
+ get: (sm) => {
568
+ const value = sm.getStatusLineSegmentOptions().time?.showSeconds;
569
+ if (value === undefined) return "default";
570
+ return value ? "on" : "off";
571
+ },
572
+ set: (sm, v) => {
573
+ if (v === "default") {
574
+ sm.clearStatusLineSegmentOption("time", "showSeconds");
575
+ } else {
576
+ sm.setStatusLineSegmentOption("time", "showSeconds", v === "on");
577
+ }
578
+ },
579
+ },
296
580
  ];
297
581
 
298
582
  /**