@oh-my-pi/pi-coding-agent 3.13.1337 → 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 (149) hide show
  1. package/CHANGELOG.md +88 -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/index.ts +1 -1
  15. package/src/core/hooks/tool-wrapper.ts +0 -1
  16. package/src/core/hooks/types.ts +2 -2
  17. package/src/core/plugins/doctor.ts +9 -1
  18. package/src/core/sdk.ts +2 -1
  19. package/src/core/session-manager.ts +552 -41
  20. package/src/core/settings-manager.ts +174 -0
  21. package/src/core/system-prompt.ts +9 -14
  22. package/src/core/title-generator.ts +2 -8
  23. package/src/core/tools/ask.ts +19 -37
  24. package/src/core/tools/bash.ts +2 -37
  25. package/src/core/tools/edit.ts +2 -9
  26. package/src/core/tools/exa/render.ts +52 -48
  27. package/src/core/tools/find.ts +10 -8
  28. package/src/core/tools/grep.ts +45 -17
  29. package/src/core/tools/ls.ts +22 -2
  30. package/src/core/tools/lsp/clients/biome-client.ts +207 -0
  31. package/src/core/tools/lsp/clients/index.ts +49 -0
  32. package/src/core/tools/lsp/clients/lsp-linter-client.ts +98 -0
  33. package/src/core/tools/lsp/config.ts +3 -0
  34. package/src/core/tools/lsp/index.ts +107 -55
  35. package/src/core/tools/lsp/render.ts +192 -79
  36. package/src/core/tools/lsp/types.ts +27 -0
  37. package/src/core/tools/lsp/utils.ts +62 -22
  38. package/src/core/tools/notebook.ts +9 -1
  39. package/src/core/tools/output.ts +37 -14
  40. package/src/core/tools/read.ts +349 -34
  41. package/src/core/tools/renderers.ts +290 -89
  42. package/src/core/tools/review.ts +12 -5
  43. package/src/core/tools/task/agents.ts +5 -5
  44. package/src/core/tools/task/commands.ts +3 -3
  45. package/src/core/tools/task/executor.ts +33 -1
  46. package/src/core/tools/task/index.ts +93 -6
  47. package/src/core/tools/task/render.ts +147 -66
  48. package/src/core/tools/task/types.ts +14 -9
  49. package/src/core/tools/web-fetch.ts +242 -103
  50. package/src/core/tools/web-search/index.ts +64 -20
  51. package/src/core/tools/web-search/providers/exa.ts +68 -172
  52. package/src/core/tools/web-search/render.ts +264 -74
  53. package/src/core/tools/write.ts +2 -8
  54. package/src/main.ts +10 -6
  55. package/src/modes/cleanup.ts +23 -0
  56. package/src/modes/index.ts +9 -4
  57. package/src/modes/interactive/components/bash-execution.ts +6 -3
  58. package/src/modes/interactive/components/branch-summary-message.ts +1 -1
  59. package/src/modes/interactive/components/compaction-summary-message.ts +1 -1
  60. package/src/modes/interactive/components/dynamic-border.ts +1 -1
  61. package/src/modes/interactive/components/extensions/extension-dashboard.ts +4 -5
  62. package/src/modes/interactive/components/extensions/extension-list.ts +18 -16
  63. package/src/modes/interactive/components/extensions/inspector-panel.ts +8 -8
  64. package/src/modes/interactive/components/hook-message.ts +2 -2
  65. package/src/modes/interactive/components/hook-selector.ts +1 -1
  66. package/src/modes/interactive/components/model-selector.ts +22 -9
  67. package/src/modes/interactive/components/oauth-selector.ts +20 -4
  68. package/src/modes/interactive/components/plugin-settings.ts +4 -2
  69. package/src/modes/interactive/components/session-selector.ts +9 -6
  70. package/src/modes/interactive/components/settings-defs.ts +285 -1
  71. package/src/modes/interactive/components/settings-selector.ts +176 -3
  72. package/src/modes/interactive/components/status-line/index.ts +4 -0
  73. package/src/modes/interactive/components/status-line/presets.ts +94 -0
  74. package/src/modes/interactive/components/status-line/segments.ts +350 -0
  75. package/src/modes/interactive/components/status-line/separators.ts +55 -0
  76. package/src/modes/interactive/components/status-line/types.ts +81 -0
  77. package/src/modes/interactive/components/status-line-segment-editor.ts +357 -0
  78. package/src/modes/interactive/components/status-line.ts +169 -233
  79. package/src/modes/interactive/components/tool-execution.ts +446 -211
  80. package/src/modes/interactive/components/tree-selector.ts +17 -6
  81. package/src/modes/interactive/components/ttsr-notification.ts +4 -4
  82. package/src/modes/interactive/components/welcome.ts +27 -19
  83. package/src/modes/interactive/interactive-mode.ts +98 -13
  84. package/src/modes/interactive/theme/dark.json +3 -2
  85. package/src/modes/interactive/theme/defaults/dark-arctic.json +111 -0
  86. package/src/modes/interactive/theme/defaults/dark-catppuccin.json +106 -0
  87. package/src/modes/interactive/theme/defaults/dark-cyberpunk.json +109 -0
  88. package/src/modes/interactive/theme/defaults/dark-dracula.json +105 -0
  89. package/src/modes/interactive/theme/defaults/dark-forest.json +103 -0
  90. package/src/modes/interactive/theme/defaults/dark-github.json +112 -0
  91. package/src/modes/interactive/theme/defaults/dark-gruvbox.json +119 -0
  92. package/src/modes/interactive/theme/defaults/dark-monochrome.json +101 -0
  93. package/src/modes/interactive/theme/defaults/dark-monokai.json +105 -0
  94. package/src/modes/interactive/theme/defaults/dark-nord.json +104 -0
  95. package/src/modes/interactive/theme/defaults/dark-ocean.json +108 -0
  96. package/src/modes/interactive/theme/defaults/dark-one.json +107 -0
  97. package/src/modes/interactive/theme/defaults/dark-retro.json +99 -0
  98. package/src/modes/interactive/theme/defaults/dark-rose-pine.json +95 -0
  99. package/src/modes/interactive/theme/defaults/dark-solarized.json +96 -0
  100. package/src/modes/interactive/theme/defaults/dark-sunset.json +106 -0
  101. package/src/modes/interactive/theme/defaults/dark-synthwave.json +102 -0
  102. package/src/modes/interactive/theme/defaults/dark-tokyo-night.json +108 -0
  103. package/src/modes/interactive/theme/defaults/index.ts +67 -0
  104. package/src/modes/interactive/theme/defaults/light-arctic.json +106 -0
  105. package/src/modes/interactive/theme/defaults/light-catppuccin.json +105 -0
  106. package/src/modes/interactive/theme/defaults/light-cyberpunk.json +103 -0
  107. package/src/modes/interactive/theme/defaults/light-forest.json +107 -0
  108. package/src/modes/interactive/theme/defaults/light-github.json +114 -0
  109. package/src/modes/interactive/theme/defaults/light-gruvbox.json +115 -0
  110. package/src/modes/interactive/theme/defaults/light-monochrome.json +100 -0
  111. package/src/modes/interactive/theme/defaults/light-ocean.json +106 -0
  112. package/src/modes/interactive/theme/defaults/light-one.json +105 -0
  113. package/src/modes/interactive/theme/defaults/light-retro.json +105 -0
  114. package/src/modes/interactive/theme/defaults/light-solarized.json +101 -0
  115. package/src/modes/interactive/theme/defaults/light-sunset.json +106 -0
  116. package/src/modes/interactive/theme/defaults/light-synthwave.json +105 -0
  117. package/src/modes/interactive/theme/defaults/light-tokyo-night.json +118 -0
  118. package/src/modes/interactive/theme/light.json +3 -2
  119. package/src/modes/interactive/theme/theme-schema.json +120 -4
  120. package/src/modes/interactive/theme/theme.ts +1228 -14
  121. package/src/prompts/branch-summary-preamble.md +3 -0
  122. package/src/prompts/branch-summary.md +28 -0
  123. package/src/prompts/compaction-summary.md +34 -0
  124. package/src/prompts/compaction-turn-prefix.md +16 -0
  125. package/src/prompts/compaction-update-summary.md +41 -0
  126. package/src/prompts/init.md +30 -0
  127. package/src/{core/tools/task/bundled-agents → prompts}/reviewer.md +6 -0
  128. package/src/prompts/summarization-system.md +3 -0
  129. package/src/prompts/system-prompt.md +27 -0
  130. package/src/{core/tools/task/bundled-agents → prompts}/task.md +2 -0
  131. package/src/prompts/title-system.md +8 -0
  132. package/src/prompts/tools/ask.md +24 -0
  133. package/src/prompts/tools/bash.md +23 -0
  134. package/src/prompts/tools/edit.md +9 -0
  135. package/src/prompts/tools/find.md +6 -0
  136. package/src/prompts/tools/grep.md +12 -0
  137. package/src/prompts/tools/lsp.md +14 -0
  138. package/src/prompts/tools/output.md +23 -0
  139. package/src/prompts/tools/read.md +25 -0
  140. package/src/prompts/tools/web-fetch.md +8 -0
  141. package/src/prompts/tools/web-search.md +10 -0
  142. package/src/prompts/tools/write.md +10 -0
  143. package/src/commands/init.md +0 -20
  144. /package/src/{core/tools/task/bundled-commands → prompts}/architect-plan.md +0 -0
  145. /package/src/{core/tools/task/bundled-agents → prompts}/browser.md +0 -0
  146. /package/src/{core/tools/task/bundled-agents → prompts}/explore.md +0 -0
  147. /package/src/{core/tools/task/bundled-commands → prompts}/implement-with-critic.md +0 -0
  148. /package/src/{core/tools/task/bundled-commands → prompts}/implement.md +0 -0
  149. /package/src/{core/tools/task/bundled-agents → prompts}/plan.md +0 -0
@@ -4,61 +4,25 @@ import { dirname, join } from "node:path";
4
4
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
5
5
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
6
6
  import type { AgentSession } from "../../../core/agent-session";
7
+ import type { StatusLineSegmentOptions, StatusLineSettings } from "../../../core/settings-manager";
7
8
  import { theme } from "../theme/theme";
9
+ import { getPreset } from "./status-line/presets";
10
+ import { renderSegment, type SegmentContext } from "./status-line/segments";
11
+ import { getSeparator } from "./status-line/separators";
8
12
 
9
- // Thinking level icons (Nerd Font)
10
- const THINKING_ICONS: Record<string, string> = {
11
- minimal: "🤨 min",
12
- low: "🤔 low",
13
- medium: "🤓 mid",
14
- high: "🤯 high",
15
- xhigh: "🧠 xhi",
16
- };
17
-
18
- // Nerd Font icons
19
- const ICONS = {
20
- model: "\uf4bc", // robot/model
21
- folder: "\uf115", // folder
22
- branch: "\ue725", // git branch
23
- sep: "\ue0b1", // powerline thin chevron
24
- tokens: "\ue26b", // coins
25
- context: "\ue70f", // window
26
- auto: "\udb80\udc68", // auto
27
- } as const;
28
-
29
- /** Create a colored text segment with background */
30
- function plSegment(content: string, fgAnsi: string, bgAnsi: string): string {
31
- return `${bgAnsi}${fgAnsi} ${content} \x1b[0m`;
32
- }
33
-
34
- /** Create separator with background */
35
- function plSep(sepAnsi: string, bgAnsi: string): string {
36
- return `${bgAnsi}${sepAnsi}${ICONS.sep}\x1b[0m`;
37
- }
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // Rendering Helpers
15
+ // ═══════════════════════════════════════════════════════════════════════════
38
16
 
39
- /** Create end cap - solid arrow transitioning bg to terminal default */
40
- function plEnd(bgAnsi: string): string {
41
- // Use the bg color as fg for the arrow (creates the triangle effect)
42
- const fgFromBg = bgAnsi.replace("\x1b[48;", "\x1b[38;");
43
- return `${fgFromBg}\ue0b0\x1b[0m`;
44
- }
45
-
46
- /**
47
- * Sanitize text for display in a single-line status.
48
- * Removes newlines, tabs, carriage returns, and other control characters.
49
- */
17
+ /** Sanitize text for display in a single-line status */
50
18
  function sanitizeStatusText(text: string): string {
51
- // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
52
19
  return text
53
20
  .replace(/[\r\n\t]/g, " ")
54
21
  .replace(/ +/g, " ")
55
22
  .trim();
56
23
  }
57
24
 
58
- /**
59
- * Find the git root directory by walking up from cwd.
60
- * Returns the path to .git/HEAD if found, null otherwise.
61
- */
25
+ /** Find the git root directory by walking up from cwd */
62
26
  function findGitHeadPath(): string | null {
63
27
  let dir = process.cwd();
64
28
  while (true) {
@@ -68,43 +32,53 @@ function findGitHeadPath(): string | null {
68
32
  }
69
33
  const parent = dirname(dir);
70
34
  if (parent === dir) {
71
- // Reached filesystem root
72
35
  return null;
73
36
  }
74
37
  dir = parent;
75
38
  }
76
39
  }
77
40
 
78
- /**
79
- * Footer component that shows pwd, token stats, and context usage
80
- */
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // StatusLineComponent
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
81
45
  export class StatusLineComponent implements Component {
82
46
  private session: AgentSession;
83
- private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name
47
+ private settings: StatusLineSettings = {};
48
+ private cachedBranch: string | null | undefined = undefined;
84
49
  private gitWatcher: FSWatcher | null = null;
85
50
  private onBranchChange: (() => void) | null = null;
86
51
  private autoCompactEnabled: boolean = true;
87
52
  private hookStatuses: Map<string, string> = new Map();
53
+ private subagentCount: number = 0;
54
+ private sessionStartTime: number = Date.now();
88
55
 
89
- // Git status caching (1s TTL to avoid excessive subprocess spawns)
56
+ // Git status caching (1s TTL)
90
57
  private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
91
58
  private gitStatusLastFetch = 0;
92
59
 
93
60
  constructor(session: AgentSession) {
94
61
  this.session = session;
62
+ // Load initial settings
63
+ this.settings = session.settingsManager?.getStatusLineSettings() ?? {};
64
+ }
65
+
66
+ updateSettings(settings: StatusLineSettings): void {
67
+ this.settings = settings;
95
68
  }
96
69
 
97
70
  setAutoCompactEnabled(enabled: boolean): void {
98
71
  this.autoCompactEnabled = enabled;
99
72
  }
100
73
 
101
- /**
102
- * Set hook status text to display in the footer.
103
- * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
104
- * ANSI escape codes for styling are preserved.
105
- * @param key - Unique key to identify this status
106
- * @param text - Status text, or undefined to clear
107
- */
74
+ setSubagentCount(count: number): void {
75
+ this.subagentCount = count;
76
+ }
77
+
78
+ setSessionStartTime(time: number): void {
79
+ this.sessionStartTime = time;
80
+ }
81
+
108
82
  setHookStatus(key: string, text: string | undefined): void {
109
83
  if (text === undefined) {
110
84
  this.hookStatuses.delete(key);
@@ -113,42 +87,32 @@ export class StatusLineComponent implements Component {
113
87
  }
114
88
  }
115
89
 
116
- /**
117
- * Set up a file watcher on .git/HEAD to detect branch changes.
118
- * Call the provided callback when branch changes.
119
- */
120
90
  watchBranch(onBranchChange: () => void): void {
121
91
  this.onBranchChange = onBranchChange;
122
92
  this.setupGitWatcher();
123
93
  }
124
94
 
125
95
  private setupGitWatcher(): void {
126
- // Clean up existing watcher
127
96
  if (this.gitWatcher) {
128
97
  this.gitWatcher.close();
129
98
  this.gitWatcher = null;
130
99
  }
131
100
 
132
101
  const gitHeadPath = findGitHeadPath();
133
- if (!gitHeadPath) {
134
- return;
135
- }
102
+ if (!gitHeadPath) return;
136
103
 
137
104
  try {
138
105
  this.gitWatcher = watch(gitHeadPath, () => {
139
- this.cachedBranch = undefined; // Invalidate cache
106
+ this.cachedBranch = undefined;
140
107
  if (this.onBranchChange) {
141
108
  this.onBranchChange();
142
109
  }
143
110
  });
144
111
  } catch {
145
- // Silently fail if we can't watch
112
+ // Silently fail
146
113
  }
147
114
  }
148
115
 
149
- /**
150
- * Clean up the file watcher
151
- */
152
116
  dispose(): void {
153
117
  if (this.gitWatcher) {
154
118
  this.gitWatcher.close();
@@ -157,16 +121,10 @@ export class StatusLineComponent implements Component {
157
121
  }
158
122
 
159
123
  invalidate(): void {
160
- // Invalidate cached branch so it gets re-read on next render
161
124
  this.cachedBranch = undefined;
162
125
  }
163
126
 
164
- /**
165
- * Get current git branch by reading .git/HEAD directly.
166
- * Returns null if not in a git repo, branch name otherwise.
167
- */
168
127
  private getCurrentBranch(): string | null {
169
- // Return cached value if available
170
128
  if (this.cachedBranch !== undefined) {
171
129
  return this.cachedBranch;
172
130
  }
@@ -180,25 +138,17 @@ export class StatusLineComponent implements Component {
180
138
  const content = readFileSync(gitHeadPath, "utf8").trim();
181
139
 
182
140
  if (content.startsWith("ref: refs/heads/")) {
183
- // Normal branch: extract branch name
184
141
  this.cachedBranch = content.slice(16);
185
142
  } else {
186
- // Detached HEAD state
187
143
  this.cachedBranch = "detached";
188
144
  }
189
145
  } catch {
190
- // Not in a git repo or error reading file
191
146
  this.cachedBranch = null;
192
147
  }
193
148
 
194
149
  return this.cachedBranch;
195
150
  }
196
151
 
197
- /**
198
- * Get git status indicators (staged, unstaged, untracked counts).
199
- * Returns null if not in a git repo.
200
- * Cached for 1s to avoid excessive subprocess spawns.
201
- */
202
152
  private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
203
153
  const now = Date.now();
204
154
  if (now - this.gitStatusLastFetch < 1000) {
@@ -218,21 +168,18 @@ export class StatusLineComponent implements Component {
218
168
 
219
169
  for (const line of output.split("\n")) {
220
170
  if (!line) continue;
221
- const x = line[0]; // Index (staged) status
222
- const y = line[1]; // Working tree status
171
+ const x = line[0];
172
+ const y = line[1];
223
173
 
224
- // Untracked files
225
174
  if (x === "?" && y === "?") {
226
175
  untracked++;
227
176
  continue;
228
177
  }
229
178
 
230
- // Staged changes (first column is not space or ?)
231
179
  if (x && x !== " " && x !== "?") {
232
180
  staged++;
233
181
  }
234
182
 
235
- // Unstaged changes (second column is not space)
236
183
  if (y && y !== " ") {
237
184
  unstaged++;
238
185
  }
@@ -248,27 +195,19 @@ export class StatusLineComponent implements Component {
248
195
  }
249
196
  }
250
197
 
251
- private buildStatusLine(): string {
198
+ private buildSegmentContext(width: number): SegmentContext {
252
199
  const state = this.session.state;
253
200
 
254
- // Calculate cumulative usage from ALL session entries
255
- let totalInput = 0;
256
- let totalOutput = 0;
257
- let totalCacheRead = 0;
258
- let totalCacheWrite = 0;
259
- let totalCost = 0;
260
-
261
- for (const entry of this.session.sessionManager.getEntries()) {
262
- if (entry.type === "message" && entry.message.role === "assistant") {
263
- totalInput += entry.message.usage.input;
264
- totalOutput += entry.message.usage.output;
265
- totalCacheRead += entry.message.usage.cacheRead;
266
- totalCacheWrite += entry.message.usage.cacheWrite;
267
- totalCost += entry.message.usage.cost.total;
268
- }
269
- }
201
+ // Get usage statistics
202
+ const usageStats = this.session.sessionManager?.getUsageStatistics() ?? {
203
+ input: 0,
204
+ output: 0,
205
+ cacheRead: 0,
206
+ cacheWrite: 0,
207
+ cost: 0,
208
+ };
270
209
 
271
- // Get context percentage from last assistant message
210
+ // Get context percentage
272
211
  const lastAssistantMessage = state.messages
273
212
  .slice()
274
213
  .reverse()
@@ -281,161 +220,158 @@ export class StatusLineComponent implements Component {
281
220
  lastAssistantMessage.usage.cacheWrite
282
221
  : 0;
283
222
  const contextWindow = state.model?.contextWindow || 0;
284
- const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
285
-
286
- // Format helpers
287
- const formatTokens = (n: number): string => {
288
- if (n < 1000) return n.toString();
289
- if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
290
- if (n < 1000000) return `${Math.round(n / 1000)}k`;
291
- if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
292
- return `${Math.round(n / 1000000)}M`;
223
+ const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
224
+
225
+ return {
226
+ session: this.session,
227
+ width,
228
+ options: this.resolveSettings().segmentOptions ?? {},
229
+ usageStats,
230
+ contextPercent,
231
+ contextWindow,
232
+ autoCompactEnabled: this.autoCompactEnabled,
233
+ subagentCount: this.subagentCount,
234
+ sessionStartTime: this.sessionStartTime,
235
+ git: {
236
+ branch: this.getCurrentBranch(),
237
+ status: this.getGitStatus(),
238
+ },
293
239
  };
240
+ }
294
241
 
295
- // ═══════════════════════════════════════════════════════════════════════
296
- // SEGMENT 1: Model
297
- // ═══════════════════════════════════════════════════════════════════════
298
- let modelName = state.model?.name || state.model?.id || "no-model";
299
- // Strip "Claude " prefix for brevity
300
- if (modelName.startsWith("Claude ")) {
301
- modelName = modelName.slice(7);
302
- }
303
- let modelContent = `${ICONS.model} ${modelName}`;
304
- if (state.model?.reasoning) {
305
- const level = state.thinkingLevel || "off";
306
- if (level !== "off") {
307
- modelContent += ` · ${THINKING_ICONS[level] ?? level}`;
308
- }
309
- }
242
+ private resolveSettings(): Required<
243
+ Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
244
+ > &
245
+ StatusLineSettings {
246
+ const preset = this.settings.preset ?? "default";
247
+ const presetDef = getPreset(preset);
248
+ const mergedSegmentOptions: StatusLineSettings["segmentOptions"] = {};
310
249
 
311
- // ═══════════════════════════════════════════════════════════════════════
312
- // SEGMENT 2: Path
313
- // ═══════════════════════════════════════════════════════════════════════
314
- let pwd = process.cwd();
315
- const home = process.env.HOME || process.env.USERPROFILE;
316
- if (home && pwd.startsWith(home)) {
317
- pwd = `~${pwd.slice(home.length)}`;
318
- }
319
- if (pwd.startsWith("/work/")) {
320
- pwd = pwd.slice(6);
321
- }
322
- const pathContent = `${ICONS.folder} ${pwd}`;
323
-
324
- // ═══════════════════════════════════════════════════════════════════════
325
- // SEGMENT 3: Git Branch + Status
326
- // ═══════════════════════════════════════════════════════════════════════
327
- const branch = this.getCurrentBranch();
328
- let gitContent = "";
329
- let gitColorName: "statusLineGitClean" | "statusLineGitDirty" = "statusLineGitClean";
330
- if (branch) {
331
- const gitStatus = this.getGitStatus();
332
- const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
333
- gitColorName = isDirty ? "statusLineGitDirty" : "statusLineGitClean";
334
-
335
- gitContent = `${ICONS.branch} ${branch}`;
336
-
337
- if (gitStatus) {
338
- const indicators: string[] = [];
339
- if (gitStatus.unstaged > 0) {
340
- indicators.push(theme.fg("statusLineDirty", `*${gitStatus.unstaged}`));
341
- }
342
- if (gitStatus.staged > 0) {
343
- indicators.push(theme.fg("statusLineStaged", `+${gitStatus.staged}`));
344
- }
345
- if (gitStatus.untracked > 0) {
346
- indicators.push(theme.fg("statusLineUntracked", `?${gitStatus.untracked}`));
347
- }
348
- if (indicators.length > 0) {
349
- gitContent += ` ${indicators.join(" ")}`;
350
- }
351
- }
250
+ for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
251
+ mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record<string, unknown>) };
352
252
  }
353
253
 
354
- // ═══════════════════════════════════════════════════════════════════════
355
- // SEGMENT 4: Context (window usage)
356
- // ═══════════════════════════════════════════════════════════════════════
357
- const autoIndicator = this.autoCompactEnabled ? ` ${ICONS.auto}` : "";
358
- const contextText = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
359
- let contextContent: string;
360
- if (contextPercentValue > 90) {
361
- contextContent = `${ICONS.context} ${theme.fg("error", contextText)}`;
362
- } else if (contextPercentValue > 70) {
363
- contextContent = `${ICONS.context} ${theme.fg("warning", contextText)}`;
364
- } else {
365
- contextContent = `${ICONS.context} ${contextText}`;
254
+ for (const [segment, options] of Object.entries(this.settings.segmentOptions ?? {})) {
255
+ const current = mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] ?? {};
256
+ mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = {
257
+ ...(current as Record<string, unknown>),
258
+ ...(options as Record<string, unknown>),
259
+ };
366
260
  }
367
261
 
368
- // ═══════════════════════════════════════════════════════════════════════
369
- // SEGMENT 5: Spend (tokens + cost)
370
- // ═══════════════════════════════════════════════════════════════════════
371
- const spendParts: string[] = [];
262
+ return {
263
+ ...this.settings,
264
+ leftSegments: this.settings.leftSegments ?? presetDef.leftSegments,
265
+ rightSegments: this.settings.rightSegments ?? presetDef.rightSegments,
266
+ separator: this.settings.separator ?? presetDef.separator,
267
+ segmentOptions: mergedSegmentOptions,
268
+ };
269
+ }
372
270
 
373
- const totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;
374
- if (totalTokens) {
375
- spendParts.push(`${ICONS.tokens} ${formatTokens(totalTokens)}`);
376
- }
271
+ private buildStatusLine(width: number): string {
272
+ const ctx = this.buildSegmentContext(width);
273
+ const effectiveSettings = this.resolveSettings();
274
+ const separatorDef = getSeparator(effectiveSettings.separator ?? "powerline-thin", theme);
275
+
276
+ const bgAnsi = theme.getBgAnsi("statusLineBg");
277
+ const fgAnsi = theme.getFgAnsi("text");
278
+ const sepAnsi = theme.getFgAnsi("statusLineSep");
377
279
 
378
- const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
379
- if (totalCost || usingSubscription) {
380
- const costDisplay = `$${totalCost.toFixed(2)}${usingSubscription ? " (sub)" : ""}`;
381
- spendParts.push(costDisplay);
280
+ // Collect visible segment contents
281
+ const leftParts: string[] = [];
282
+ for (const segId of effectiveSettings.leftSegments) {
283
+ const rendered = renderSegment(segId, ctx);
284
+ if (rendered.visible && rendered.content) {
285
+ leftParts.push(rendered.content);
286
+ }
382
287
  }
383
288
 
384
- const spendContent = theme.fg("statusLineCost", spendParts.join(" · "));
289
+ const rightParts: string[] = [];
290
+ for (const segId of effectiveSettings.rightSegments) {
291
+ const rendered = renderSegment(segId, ctx);
292
+ if (rendered.visible && rendered.content) {
293
+ rightParts.push(rendered.content);
294
+ }
295
+ }
385
296
 
386
- // ═══════════════════════════════════════════════════════════════════════
387
- // Assemble: [Model] > [Path] > [Git?] > [Context] > [Spend] >
388
- // ═══════════════════════════════════════════════════════════════════════
389
- const bgAnsi = theme.getBgAnsi("statusLineBg");
390
- const sepAnsi = theme.getFgAnsi("statusLineSep");
297
+ const topFillWidth = width > 0 ? Math.max(0, width - 4) : 0;
298
+ const left = [...leftParts];
299
+ const right = [...rightParts];
391
300
 
392
- let statusLine = "";
301
+ const leftSepWidth = visibleWidth(separatorDef.left);
302
+ const rightSepWidth = visibleWidth(separatorDef.right);
303
+ const leftCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.right) : 0;
304
+ const rightCapWidth = separatorDef.endCaps ? visibleWidth(separatorDef.endCaps.left) : 0;
393
305
 
394
- // Model segment
395
- statusLine += plSegment(modelContent, theme.getFgAnsi("statusLineModel"), bgAnsi);
396
- statusLine += plSep(sepAnsi, bgAnsi);
306
+ const groupWidth = (parts: string[], capWidth: number, sepWidth: number): number => {
307
+ if (parts.length === 0) return 0;
308
+ const partsWidth = parts.reduce((sum, part) => sum + visibleWidth(part), 0);
309
+ const sepTotal = Math.max(0, parts.length - 1) * (sepWidth + 2);
310
+ return partsWidth + sepTotal + 2 + capWidth;
311
+ };
397
312
 
398
- // Path segment
399
- statusLine += plSegment(pathContent, theme.getFgAnsi("statusLinePath"), bgAnsi);
313
+ let leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
314
+ let rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
315
+ const totalWidth = () => leftWidth + rightWidth + (left.length > 0 && right.length > 0 ? 1 : 0);
400
316
 
401
- if (gitContent) {
402
- statusLine += plSep(sepAnsi, bgAnsi);
403
- statusLine += plSegment(gitContent, theme.getFgAnsi(gitColorName), bgAnsi);
317
+ if (topFillWidth > 0) {
318
+ while (totalWidth() > topFillWidth && right.length > 0) {
319
+ right.pop();
320
+ rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
321
+ }
322
+ while (totalWidth() > topFillWidth && left.length > 0) {
323
+ left.pop();
324
+ leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
325
+ }
404
326
  }
405
327
 
406
- // Context segment
407
- statusLine += plSep(sepAnsi, bgAnsi);
408
- statusLine += plSegment(contextContent, theme.getFgAnsi("statusLineContext"), bgAnsi);
328
+ const renderGroup = (parts: string[], direction: "left" | "right"): string => {
329
+ if (parts.length === 0) return "";
330
+ const sep = direction === "left" ? separatorDef.left : separatorDef.right;
331
+ const cap = separatorDef.endCaps
332
+ ? direction === "left"
333
+ ? separatorDef.endCaps.right
334
+ : separatorDef.endCaps.left
335
+ : "";
336
+ const capPrefix = separatorDef.endCaps?.useBgAsFg ? bgAnsi.replace("\x1b[48;", "\x1b[38;") : sepAnsi;
337
+ const capText = cap ? `${capPrefix}${cap}\x1b[0m` : "";
338
+
339
+ let content = bgAnsi + fgAnsi;
340
+ content += ` ${parts.join(` ${sepAnsi}${sep}${fgAnsi} `)} `;
341
+ content += "\x1b[0m";
342
+
343
+ if (capText) {
344
+ return direction === "right" ? capText + content : content + capText;
345
+ }
346
+ return content;
347
+ };
409
348
 
410
- // Spend segment
411
- statusLine += plSep(sepAnsi, bgAnsi);
412
- statusLine += plSegment(spendContent, theme.getFgAnsi("statusLineSpend"), bgAnsi);
349
+ const leftGroup = renderGroup(left, "left");
350
+ const rightGroup = renderGroup(right, "right");
351
+ if (!leftGroup && !rightGroup) return "";
413
352
 
414
- // End cap (solid arrow to terminal bg)
415
- statusLine += plEnd(bgAnsi);
353
+ if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
354
+ return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
355
+ }
416
356
 
417
- return statusLine;
357
+ leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
358
+ rightWidth = groupWidth(right, rightCapWidth, rightSepWidth);
359
+ const gapWidth = Math.max(1, topFillWidth - leftWidth - rightWidth);
360
+ return leftGroup + " ".repeat(gapWidth) + rightGroup;
418
361
  }
419
362
 
420
- /**
421
- * Get the status line content for use as editor top border.
422
- * Returns the content string and its visible width.
423
- */
424
- getTopBorder(_width: number): { content: string; width: number } {
425
- const content = this.buildStatusLine();
363
+ getTopBorder(width: number): { content: string; width: number } {
364
+ const content = this.buildStatusLine(width);
426
365
  return {
427
366
  content,
428
367
  width: visibleWidth(content),
429
368
  };
430
369
  }
431
370
 
432
- /**
433
- * Render only hook statuses (if any).
434
- * Used when footer is integrated into editor border.
435
- */
436
371
  render(width: number): string[] {
437
372
  // Only render hook statuses - main status is in editor's top border
438
- if (this.hookStatuses.size === 0) {
373
+ const showHooks = this.settings.showHookStatus ?? true;
374
+ if (!showHooks || this.hookStatuses.size === 0) {
439
375
  return [];
440
376
  }
441
377
 
@@ -443,6 +379,6 @@ export class StatusLineComponent implements Component {
443
379
  .sort(([a], [b]) => a.localeCompare(b))
444
380
  .map(([, text]) => sanitizeStatusText(text));
445
381
  const hookLine = sortedStatuses.join(" ");
446
- return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", "…"))];
382
+ return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", theme.format.ellipsis))];
447
383
  }
448
384
  }