@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
@@ -10,17 +10,11 @@ import type { Theme } from "../../../modes/interactive/theme/theme";
10
10
  import type { RenderResultOptions } from "../../custom-tools/types";
11
11
  import type { WebSearchResponse } from "./types";
12
12
 
13
- // Tree formatting constants
14
- const TREE_MID = "├─";
15
- const TREE_END = "└─";
16
- const TREE_PIPE = "│";
17
- const TREE_SPACE = " ";
18
- const TREE_HOOK = "⎿";
19
-
20
13
  /** Truncate text to max length with ellipsis */
21
- export function truncate(text: string, maxLen: number): string {
14
+ export function truncate(text: string, maxLen: number, ellipsis: string): string {
22
15
  if (text.length <= maxLen) return text;
23
- return `${text.slice(0, maxLen - 1)}…`;
16
+ const sliceLen = Math.max(0, maxLen - ellipsis.length);
17
+ return `${text.slice(0, sliceLen)}${ellipsis}`;
24
18
  }
25
19
 
26
20
  /** Extract domain from URL */
@@ -51,9 +45,55 @@ export function formatAge(ageSeconds: number | null | undefined): string {
51
45
  }
52
46
 
53
47
  /** Get first N lines of text as preview */
54
- export function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] {
48
+ export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis: string): string[] {
55
49
  const lines = text.split("\n").filter((l) => l.trim());
56
- return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen));
50
+ return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen, ellipsis));
51
+ }
52
+
53
+ const MAX_COLLAPSED_ANSWER_LINES = 3;
54
+ const MAX_EXPANDED_ANSWER_LINES = 12;
55
+ const MAX_ANSWER_LINE_LEN = 110;
56
+ const MAX_SNIPPET_LINES = 2;
57
+ const MAX_SNIPPET_LINE_LEN = 110;
58
+ const MAX_RELATED_QUESTIONS = 6;
59
+ const MAX_QUERY_PREVIEW = 2;
60
+ const MAX_QUERY_LEN = 90;
61
+ const MAX_REQUEST_ID_LEN = 36;
62
+
63
+ function formatCount(label: string, count: number): string {
64
+ const safeCount = Number.isFinite(count) ? count : 0;
65
+ return `${safeCount} ${label}${safeCount === 1 ? "" : "s"}`;
66
+ }
67
+
68
+ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme): Component {
69
+ const lines = contentText.split("\n").filter((line) => line.trim());
70
+ const maxLines = expanded ? lines.length : 6;
71
+ const displayLines = lines.slice(0, maxLines).map((line) => truncate(line.trim(), 110, theme.format.ellipsis));
72
+ const remaining = lines.length - displayLines.length;
73
+
74
+ const headerIcon = theme.fg("warning", theme.status.warning);
75
+ const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
76
+ let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")}${expandHint}`;
77
+
78
+ if (displayLines.length === 0) {
79
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", "No response data")}`;
80
+ return new Text(text, 0, 0);
81
+ }
82
+
83
+ for (let i = 0; i < displayLines.length; i++) {
84
+ const isLast = i === displayLines.length - 1 && remaining === 0;
85
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
86
+ text += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", displayLines[i])}`;
87
+ }
88
+
89
+ if (!expanded && remaining > 0) {
90
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
91
+ "muted",
92
+ `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
93
+ )}`;
94
+ }
95
+
96
+ return new Text(text, 0, 0);
57
97
  }
58
98
 
59
99
  export interface WebSearchRenderDetails {
@@ -75,95 +115,245 @@ export function renderWebSearchResult(
75
115
  return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
76
116
  }
77
117
 
118
+ const rawText = result.content?.find((block) => block.type === "text")?.text?.trim() ?? "";
78
119
  const response = details?.response;
79
120
  if (!response) {
80
- return new Text(theme.fg("error", "No response data"), 0, 0);
121
+ return renderFallbackText(rawText, expanded, theme);
81
122
  }
82
123
 
83
- const sources = response.sources ?? [];
124
+ const sources = Array.isArray(response.sources) ? response.sources : [];
84
125
  const sourceCount = sources.length;
85
- const _modelName = response.model ?? response.provider;
126
+ const citations = Array.isArray(response.citations) ? response.citations : [];
127
+ const citationCount = citations.length;
128
+ const related = Array.isArray(response.relatedQuestions)
129
+ ? response.relatedQuestions.filter((item) => typeof item === "string")
130
+ : [];
131
+ const relatedCount = related.length;
132
+ const searchQueries = Array.isArray(response.searchQueries)
133
+ ? response.searchQueries.filter((item) => typeof item === "string")
134
+ : [];
86
135
  const provider = response.provider;
87
136
 
88
- // Build header: Web Search (provider/model) · N sources
89
- const icon = sourceCount > 0 ? theme.fg("success", "●") : theme.fg("warning", "●");
137
+ // Build header: status icon Web Search (provider) · counts
138
+ const providerLabel =
139
+ provider === "anthropic"
140
+ ? "Anthropic"
141
+ : provider === "perplexity"
142
+ ? "Perplexity"
143
+ : provider === "exa"
144
+ ? "Exa"
145
+ : "Unknown";
146
+ const headerIcon =
147
+ sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning);
90
148
  const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
91
- const providerLabel = provider === "anthropic" ? "Anthropic" : "Perplexity";
92
- let text = `${icon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)} · ${theme.fg(
149
+ let text = `${headerIcon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)}${theme.sep.dot}${theme.fg(
93
150
  "dim",
94
- `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`,
151
+ formatCount("source", sourceCount),
95
152
  )}${expandHint}`;
96
153
 
97
154
  // Get answer text
98
- const contentText = response.answer ?? result.content[0]?.text ?? "";
155
+ const answerText = typeof response.answer === "string" ? response.answer.trim() : "";
156
+ const contentText = answerText || rawText;
157
+ const totalAnswerLines = contentText ? contentText.split("\n").filter((l) => l.trim()).length : 0;
158
+ const answerLimit = expanded ? MAX_EXPANDED_ANSWER_LINES : MAX_COLLAPSED_ANSWER_LINES;
159
+ const answerPreview = contentText
160
+ ? getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN, theme.format.ellipsis)
161
+ : [];
99
162
 
100
163
  if (!expanded) {
101
- // Collapsed view: show 2-3 preview lines of answer
102
- const previewLines = getPreviewLines(contentText, 3, 100);
103
- for (const line of previewLines) {
104
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", line)}`;
105
- }
106
- const totalLines = contentText.split("\n").filter((l) => l.trim()).length;
107
- if (totalLines > 3) {
108
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("muted", `… ${totalLines - 3} more lines`)}`;
109
- }
164
+ const answerTitle = `${theme.fg("accent", theme.status.info)} ${theme.fg("accent", "Answer")}`;
165
+ text += `\n ${theme.fg("dim", theme.tree.vertical)} ${answerTitle}`;
110
166
 
111
- // Show source count summary
112
- if (sourceCount > 0) {
113
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg(
167
+ if (answerPreview.length === 0) {
168
+ text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
114
169
  "muted",
115
- `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`,
170
+ "No answer text returned",
116
171
  )}`;
172
+ } else {
173
+ for (const line of answerPreview) {
174
+ text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
175
+ "dim",
176
+ line,
177
+ )}`;
178
+ }
117
179
  }
118
- } else {
119
- // Expanded view: full answer + source tree
120
- const answerLines = contentText.split("\n");
121
- for (const line of answerLines) {
122
- text += `\n ${theme.fg("dim", TREE_PIPE)} ${line}`;
180
+
181
+ const remaining = totalAnswerLines - answerPreview.length;
182
+ if (remaining > 0) {
183
+ text += `\n ${theme.fg("dim", theme.tree.vertical)} ${theme.fg("dim", `${theme.tree.hook} `)}${theme.fg(
184
+ "muted",
185
+ `${theme.format.ellipsis} ${remaining} more line${remaining === 1 ? "" : "s"}`,
186
+ )}`;
123
187
  }
124
188
 
125
- // Render sources as tree
126
- const hasRelatedQuestions = response.relatedQuestions && response.relatedQuestions.length > 0;
189
+ const summary = [
190
+ formatCount("source", sourceCount),
191
+ formatCount("citation", citationCount),
192
+ formatCount("related", relatedCount),
193
+ ].join(theme.sep.dot);
194
+ text += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", summary)}`;
195
+ return new Text(text, 0, 0);
196
+ }
127
197
 
128
- if (sourceCount > 0) {
129
- text += `\n ${theme.fg("dim", TREE_PIPE)}`;
130
- const sourcesBranch = hasRelatedQuestions ? TREE_MID : TREE_END;
131
- text += `\n ${theme.fg("dim", sourcesBranch)} ${theme.fg("accent", "Sources")}`;
198
+ const answerLines = answerPreview.length > 0 ? answerPreview : ["No answer text returned"];
199
+ const answerSectionLines = answerLines.map((line) =>
200
+ line === "No answer text returned" ? theme.fg("muted", line) : theme.fg("text", line),
201
+ );
202
+ const remainingAnswer = totalAnswerLines - answerPreview.length;
203
+ if (remainingAnswer > 0) {
204
+ answerSectionLines.push(
205
+ theme.fg("muted", `${theme.format.ellipsis} ${remainingAnswer} more line${remainingAnswer === 1 ? "" : "s"}`),
206
+ );
207
+ }
132
208
 
133
- for (let i = 0; i < sources.length; i++) {
134
- const src = sources[i];
135
- const isLast = i === sources.length - 1;
136
- const branch = isLast ? TREE_END : TREE_MID;
137
- const cont = isLast ? TREE_SPACE : TREE_PIPE;
138
- const indent = hasRelatedQuestions ? TREE_PIPE : TREE_SPACE;
209
+ const sourceLines: string[] = [];
210
+ if (sourceCount === 0) {
211
+ sourceLines.push(theme.fg("muted", "No sources returned"));
212
+ } else {
213
+ for (const src of sources) {
214
+ const titleText =
215
+ typeof src.title === "string" && src.title.trim()
216
+ ? src.title
217
+ : typeof src.url === "string" && src.url.trim()
218
+ ? src.url
219
+ : "Untitled";
220
+ const title = truncate(titleText, 70, theme.format.ellipsis);
221
+ const url = typeof src.url === "string" ? src.url : "";
222
+ const domain = url ? getDomain(url) : "";
223
+ const age = formatAge(src.ageSeconds) || (typeof src.publishedDate === "string" ? src.publishedDate : "");
224
+ const metaParts: string[] = [];
225
+ if (domain) {
226
+ metaParts.push(theme.fg("dim", `(${domain})`));
227
+ }
228
+ if (typeof src.author === "string" && src.author.trim()) {
229
+ metaParts.push(theme.fg("muted", src.author));
230
+ }
231
+ if (age) {
232
+ metaParts.push(theme.fg("muted", age));
233
+ }
234
+ const metaSep = theme.fg("dim", theme.sep.dot);
235
+ const metaSuffix = metaParts.length > 0 ? ` ${metaParts.join(metaSep)}` : "";
236
+ sourceLines.push(`${theme.fg("accent", title)}${metaSuffix}`);
139
237
 
140
- // Title + domain + age
141
- const title = truncate(src.title, 60);
142
- const domain = getDomain(src.url);
143
- const age = formatAge(src.ageSeconds) || src.publishedDate;
144
- const agePart = age ? theme.fg("muted", ` · ${age}`) : "";
238
+ const snippetText = typeof src.snippet === "string" ? src.snippet : "";
239
+ if (snippetText.trim()) {
240
+ const snippetLines = getPreviewLines(
241
+ snippetText,
242
+ MAX_SNIPPET_LINES,
243
+ MAX_SNIPPET_LINE_LEN,
244
+ theme.format.ellipsis,
245
+ );
246
+ for (const snippetLine of snippetLines) {
247
+ sourceLines.push(theme.fg("muted", `${theme.format.dash} ${snippetLine}`));
248
+ }
249
+ }
145
250
 
146
- text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)} ${theme.fg(
147
- "dim",
148
- `(${domain})`,
149
- )}${agePart}`;
150
- text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${cont} ${TREE_HOOK} `)}${theme.fg(
151
- "mdLinkUrl",
152
- src.url,
153
- )}`;
251
+ if (url) {
252
+ sourceLines.push(theme.fg("mdLinkUrl", url));
154
253
  }
155
254
  }
255
+ }
156
256
 
157
- // Render related questions (Perplexity only)
158
- if (hasRelatedQuestions) {
159
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("accent", "Related Questions")}`;
160
- const questions = response.relatedQuestions!;
161
- for (let i = 0; i < questions.length; i++) {
162
- const question = questions[i];
163
- const isLast = i === questions.length - 1;
164
- const branch = isLast ? TREE_END : TREE_MID;
165
- text += `\n ${theme.fg("dim", TREE_SPACE)} ${theme.fg("dim", branch)} ${theme.fg("muted", question)}`;
166
- }
257
+ const relatedLines: string[] = [];
258
+ if (relatedCount === 0) {
259
+ relatedLines.push(theme.fg("muted", "No related questions"));
260
+ } else {
261
+ const maxRelated = Math.min(MAX_RELATED_QUESTIONS, related.length);
262
+ for (let i = 0; i < maxRelated; i++) {
263
+ relatedLines.push(theme.fg("muted", `${theme.format.dash} ${related[i]}`));
264
+ }
265
+ if (relatedCount > maxRelated) {
266
+ relatedLines.push(
267
+ theme.fg(
268
+ "muted",
269
+ `${theme.format.ellipsis} ${relatedCount - maxRelated} more question${
270
+ relatedCount - maxRelated === 1 ? "" : "s"
271
+ }`,
272
+ ),
273
+ );
274
+ }
275
+ }
276
+
277
+ const metaLines: string[] = [];
278
+ metaLines.push(`${theme.fg("muted", "Provider:")} ${theme.fg("text", providerLabel)}`);
279
+ if (response.model) {
280
+ metaLines.push(`${theme.fg("muted", "Model:")} ${theme.fg("text", response.model)}`);
281
+ }
282
+ metaLines.push(`${theme.fg("muted", "Sources:")} ${theme.fg("text", String(sourceCount))}`);
283
+ if (citationCount > 0) {
284
+ metaLines.push(`${theme.fg("muted", "Citations:")} ${theme.fg("text", String(citationCount))}`);
285
+ }
286
+ if (relatedCount > 0) {
287
+ metaLines.push(`${theme.fg("muted", "Related:")} ${theme.fg("text", String(relatedCount))}`);
288
+ }
289
+ if (response.usage) {
290
+ const usageParts: string[] = [];
291
+ if (response.usage.inputTokens !== undefined) usageParts.push(`in ${response.usage.inputTokens}`);
292
+ if (response.usage.outputTokens !== undefined) usageParts.push(`out ${response.usage.outputTokens}`);
293
+ if (response.usage.totalTokens !== undefined) usageParts.push(`total ${response.usage.totalTokens}`);
294
+ if (response.usage.searchRequests !== undefined) usageParts.push(`search ${response.usage.searchRequests}`);
295
+ if (usageParts.length > 0) {
296
+ metaLines.push(`${theme.fg("muted", "Usage:")} ${theme.fg("text", usageParts.join(theme.sep.dot))}`);
297
+ }
298
+ }
299
+ if (response.requestId) {
300
+ metaLines.push(
301
+ `${theme.fg("muted", "Request:")} ${theme.fg(
302
+ "text",
303
+ truncate(response.requestId, MAX_REQUEST_ID_LEN, theme.format.ellipsis),
304
+ )}`,
305
+ );
306
+ }
307
+ if (searchQueries.length > 0) {
308
+ metaLines.push(`${theme.fg("muted", "Search queries:")} ${theme.fg("text", String(searchQueries.length))}`);
309
+ const queryPreview = searchQueries.slice(0, MAX_QUERY_PREVIEW);
310
+ for (const q of queryPreview) {
311
+ metaLines.push(theme.fg("muted", `${theme.format.dash} ${truncate(q, MAX_QUERY_LEN, theme.format.ellipsis)}`));
312
+ }
313
+ if (searchQueries.length > MAX_QUERY_PREVIEW) {
314
+ metaLines.push(
315
+ theme.fg(
316
+ "muted",
317
+ `${theme.format.ellipsis} ${searchQueries.length - MAX_QUERY_PREVIEW} more query${
318
+ searchQueries.length - MAX_QUERY_PREVIEW === 1 ? "" : "s"
319
+ }`,
320
+ ),
321
+ );
322
+ }
323
+ }
324
+
325
+ const sections: Array<{ title: string; icon: string; lines: string[] }> = [
326
+ {
327
+ title: "Answer",
328
+ icon: theme.fg("accent", theme.status.info),
329
+ lines: answerSectionLines,
330
+ },
331
+ {
332
+ title: "Sources",
333
+ icon: sourceCount > 0 ? theme.fg("success", theme.status.success) : theme.fg("warning", theme.status.warning),
334
+ lines: sourceLines,
335
+ },
336
+ {
337
+ title: "Related",
338
+ icon: relatedCount > 0 ? theme.fg("accent", theme.status.info) : theme.fg("warning", theme.status.warning),
339
+ lines: relatedLines,
340
+ },
341
+ {
342
+ title: "Meta",
343
+ icon: theme.fg("accent", theme.status.info),
344
+ lines: metaLines,
345
+ },
346
+ ];
347
+
348
+ for (let i = 0; i < sections.length; i++) {
349
+ const section = sections[i];
350
+ const isLast = i === sections.length - 1;
351
+ const branch = isLast ? theme.tree.last : theme.tree.branch;
352
+ const indent = isLast ? " " : theme.tree.vertical;
353
+
354
+ text += `\n ${theme.fg("dim", branch)} ${section.icon} ${theme.fg("accent", section.title)}`;
355
+ for (const line of section.lines) {
356
+ text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${theme.tree.hook} `)}${line}`;
167
357
  }
168
358
  }
169
359
 
@@ -176,7 +366,7 @@ export function renderWebSearchCall(
176
366
  theme: Theme,
177
367
  ): Component {
178
368
  const provider = args.provider ?? "auto";
179
- const query = truncate(args.query, 80);
369
+ const query = truncate(args.query, 80, theme.format.ellipsis);
180
370
  const text = `${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${provider})`)} ${theme.fg("muted", query)}`;
181
371
  return new Text(text, 0, 0);
182
372
  }
@@ -1,5 +1,6 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import { Type } from "@sinclair/typebox";
3
+ import writeDescription from "../../prompts/tools/write.md" with { type: "text" };
3
4
  import { type FileDiagnosticsResult, type WritethroughCallback, writethroughNoop } from "./lsp/index";
4
5
  import { resolveToCwd } from "./path-utils";
5
6
 
@@ -26,14 +27,7 @@ export function createWriteTool(
26
27
  return {
27
28
  name: "write",
28
29
  label: "Write",
29
- description: `Writes a file to the local filesystem.
30
-
31
- Usage:
32
- - This tool will overwrite the existing file if there is one at the provided path.
33
- - If this is an existing file, you MUST use the read tool first to read the file's contents. This tool will fail if you did not read the file first.
34
- - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
35
- - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
36
- - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.`,
30
+ description: writeDescription,
37
31
  parameters: writeSchema,
38
32
  execute: async (
39
33
  _toolCallId: string,
package/src/main.ts CHANGED
@@ -176,15 +176,15 @@ function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager):
176
176
  return undefined;
177
177
  }
178
178
 
179
- function createSessionManager(parsed: Args, cwd: string): SessionManager | undefined {
179
+ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
180
180
  if (parsed.noSession) {
181
181
  return SessionManager.inMemory();
182
182
  }
183
183
  if (parsed.session) {
184
- return SessionManager.open(parsed.session, parsed.sessionDir);
184
+ return await SessionManager.open(parsed.session, parsed.sessionDir);
185
185
  }
186
186
  if (parsed.continue) {
187
- return SessionManager.continueRecent(cwd, parsed.sessionDir);
187
+ return await SessionManager.continueRecent(cwd, parsed.sessionDir);
188
188
  }
189
189
  // --resume is handled separately (needs picker UI)
190
190
  // If --session-dir provided without --continue/--resume, create new session there
@@ -294,6 +294,10 @@ async function buildSessionOptions(
294
294
  export async function main(args: string[]) {
295
295
  time("start");
296
296
 
297
+ // Initialize theme early with defaults (CLI commands need symbols)
298
+ // Will be re-initialized with user preferences later
299
+ initTheme();
300
+
297
301
  // Handle plugin subcommand before regular parsing
298
302
  const pluginCmd = parsePluginArgs(args);
299
303
  if (pluginCmd) {
@@ -385,7 +389,7 @@ export async function main(args: string[]) {
385
389
  settingsManager.applyOverrides({ modelRoles: roleOverrides });
386
390
  }
387
391
 
388
- initTheme(settingsManager.getTheme(), isInteractive);
392
+ initTheme(settingsManager.getTheme(), isInteractive, settingsManager.getSymbolPreset());
389
393
  time("initTheme");
390
394
 
391
395
  let scopedModels: ScopedModel[] = [];
@@ -396,7 +400,7 @@ export async function main(args: string[]) {
396
400
  }
397
401
 
398
402
  // Create session manager based on CLI flags
399
- let sessionManager = createSessionManager(parsed, cwd);
403
+ let sessionManager = await createSessionManager(parsed, cwd);
400
404
  time("createSessionManager");
401
405
 
402
406
  // Handle --resume: show session picker
@@ -413,7 +417,7 @@ export async function main(args: string[]) {
413
417
  console.log(chalk.dim("No session selected"));
414
418
  return;
415
419
  }
416
- sessionManager = SessionManager.open(selectedPath);
420
+ sessionManager = await SessionManager.open(selectedPath);
417
421
  }
418
422
 
419
423
  const sessionOptions = await buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry);
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Async cleanup registry for graceful shutdown on signals.
3
+ */
4
+
5
+ /** Registry of async cleanup callbacks to run on shutdown/signals */
6
+ const asyncCleanupCallbacks: (() => Promise<void>)[] = [];
7
+
8
+ /**
9
+ * Register an async cleanup callback to be run on process signals (SIGINT, SIGTERM, SIGHUP).
10
+ * Returns an unsubscribe function.
11
+ */
12
+ export function registerAsyncCleanup(callback: () => Promise<void>): () => void {
13
+ asyncCleanupCallbacks.push(callback);
14
+ return () => {
15
+ const index = asyncCleanupCallbacks.indexOf(callback);
16
+ if (index >= 0) asyncCleanupCallbacks.splice(index, 1);
17
+ };
18
+ }
19
+
20
+ /** Run all registered async cleanup callbacks, settling all promises */
21
+ export async function runAsyncCleanup(): Promise<void> {
22
+ await Promise.allSettled(asyncCleanupCallbacks.map((cb) => cb()));
23
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui";
6
+ import { runAsyncCleanup } from "./cleanup";
6
7
 
7
8
  /**
8
9
  * Install handlers that restore terminal state on crash/signal.
@@ -13,17 +14,21 @@ export function installTerminalCrashHandlers(): void {
13
14
  emergencyTerminalRestore();
14
15
  };
15
16
 
16
- // Signals
17
+ // Signals - run async cleanup before exit
18
+ process.on("SIGINT", () => {
19
+ cleanup();
20
+ void runAsyncCleanup().finally(() => process.exit(128 + 2));
21
+ });
17
22
  process.on("SIGTERM", () => {
18
23
  cleanup();
19
- process.exit(128 + 15);
24
+ void runAsyncCleanup().finally(() => process.exit(128 + 15));
20
25
  });
21
26
  process.on("SIGHUP", () => {
22
27
  cleanup();
23
- process.exit(128 + 1);
28
+ void runAsyncCleanup().finally(() => process.exit(128 + 1));
24
29
  });
25
30
 
26
- // Crashes
31
+ // Crashes - exit immediately (async cleanup may not be safe in corrupted state)
27
32
  process.on("uncaughtException", (err) => {
28
33
  cleanup();
29
34
  console.error("Uncaught exception:", err);
@@ -10,7 +10,7 @@ import {
10
10
  type TruncationResult,
11
11
  truncateTail,
12
12
  } from "../../../core/tools/truncate";
13
- import { theme } from "../theme/theme";
13
+ import { getSymbolTheme, theme } from "../theme/theme";
14
14
  import { DynamicBorder } from "./dynamic-border";
15
15
  import { truncateToVisualLines } from "./visual-truncate";
16
16
 
@@ -55,7 +55,8 @@ export class BashExecutionComponent extends Container {
55
55
  ui,
56
56
  (spinner) => theme.fg("bashMode", spinner),
57
57
  (text) => theme.fg("muted", text),
58
- "Running... (esc to cancel)",
58
+ `Running${theme.format.ellipsis} (esc to cancel)`,
59
+ getSymbolTheme().spinnerFrames,
59
60
  );
60
61
  this.contentContainer.addChild(this.loader);
61
62
 
@@ -159,7 +160,9 @@ export class BashExecutionComponent extends Container {
159
160
 
160
161
  // Show how many lines are hidden (collapsed preview)
161
162
  if (hiddenLineCount > 0) {
162
- statusParts.push(theme.fg("dim", `... ${hiddenLineCount} more lines (ctrl+o to expand)`));
163
+ statusParts.push(
164
+ theme.fg("dim", `${theme.format.ellipsis} ${hiddenLineCount} more lines (ctrl+o to expand)`),
165
+ );
163
166
  }
164
167
 
165
168
  if (this.status === "cancelled") {
@@ -24,7 +24,7 @@ export class BranchSummaryMessageComponent extends Box {
24
24
  private updateDisplay(): void {
25
25
  this.clear();
26
26
 
27
- const label = theme.fg("customMessageLabel", `\x1b[1m[branch]\x1b[22m`);
27
+ const label = theme.fg("customMessageLabel", theme.bold("[branch]"));
28
28
  this.addChild(new Text(label, 0, 0));
29
29
  this.addChild(new Spacer(1));
30
30
 
@@ -25,7 +25,7 @@ export class CompactionSummaryMessageComponent extends Box {
25
25
  this.clear();
26
26
 
27
27
  const tokenStr = this.message.tokensBefore.toLocaleString();
28
- const label = theme.fg("customMessageLabel", `\x1b[1m[compaction]\x1b[22m`);
28
+ const label = theme.fg("customMessageLabel", theme.bold("[compaction]"));
29
29
  this.addChild(new Text(label, 0, 0));
30
30
  this.addChild(new Spacer(1));
31
31
 
@@ -20,6 +20,6 @@ export class DynamicBorder implements Component {
20
20
  }
21
21
 
22
22
  render(width: number): string[] {
23
- return [this.color("─".repeat(Math.max(1, width)))];
23
+ return [this.color(theme.boxSharp.horizontal.repeat(Math.max(1, width)))];
24
24
  }
25
25
  }
@@ -117,18 +117,17 @@ export class ExtensionDashboard extends Container {
117
117
  label += ` (${tab.count})`;
118
118
  }
119
119
 
120
- // Apply strikethrough for disabled providers
121
- const displayLabel = isDisabled ? `${label.split("").join("\u0336")}\u0336` : label;
120
+ const displayLabel = isDisabled ? `${theme.status.disabled} ${label}` : label;
122
121
 
123
122
  if (isActive) {
124
123
  // Active tab: background highlight
125
124
  parts.push(theme.bg("selectedBg", ` ${displayLabel} `));
126
125
  } else if (isDisabled) {
127
- // Disabled provider: strikethrough + dim
126
+ // Disabled provider: dim
128
127
  parts.push(theme.fg("dim", ` ${displayLabel} `));
129
128
  } else if (isEmpty) {
130
129
  // Empty enabled provider: very dim, unselectable
131
- parts.push(`\x1b[38;5;238m ${label} \x1b[0m`);
130
+ parts.push(theme.fg("dim", ` ${label} `));
132
131
  } else {
133
132
  // Normal enabled provider
134
133
  parts.push(theme.fg("muted", ` ${label} `));
@@ -278,7 +277,7 @@ class TwoColumnBody implements Component {
278
277
 
279
278
  const maxLines = Math.max(leftLines.length, rightLines.length);
280
279
  const combined: string[] = [];
281
- const separator = theme.fg("dim", " ");
280
+ const separator = theme.fg("dim", ` ${theme.boxSharp.vertical} `);
282
281
 
283
282
  for (let i = 0; i < maxLines; i++) {
284
283
  const left = truncateToWidth(leftLines[i] ?? "", leftWidth);