@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
@@ -4,62 +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: "\uec19", // 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
- pi: "\ue22c", // pi
28
- } as const;
29
-
30
- /** Create a colored text segment with background */
31
- function plSegment(content: string, fgAnsi: string, bgAnsi: string): string {
32
- return `${bgAnsi}${fgAnsi} ${content} \x1b[0m`;
33
- }
34
-
35
- /** Create separator with background */
36
- function plSep(sepAnsi: string, bgAnsi: string): string {
37
- return `${bgAnsi}${sepAnsi}${ICONS.sep}\x1b[0m`;
38
- }
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // Rendering Helpers
15
+ // ═══════════════════════════════════════════════════════════════════════════
39
16
 
40
- /** Create end cap - solid arrow transitioning bg to terminal default */
41
- function plEnd(bgAnsi: string): string {
42
- // Use the bg color as fg for the arrow (creates the triangle effect)
43
- const fgFromBg = bgAnsi.replace("\x1b[48;", "\x1b[38;");
44
- return `${fgFromBg}\ue0b0\x1b[0m`;
45
- }
46
-
47
- /**
48
- * Sanitize text for display in a single-line status.
49
- * Removes newlines, tabs, carriage returns, and other control characters.
50
- */
17
+ /** Sanitize text for display in a single-line status */
51
18
  function sanitizeStatusText(text: string): string {
52
- // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces
53
19
  return text
54
20
  .replace(/[\r\n\t]/g, " ")
55
21
  .replace(/ +/g, " ")
56
22
  .trim();
57
23
  }
58
24
 
59
- /**
60
- * Find the git root directory by walking up from cwd.
61
- * Returns the path to .git/HEAD if found, null otherwise.
62
- */
25
+ /** Find the git root directory by walking up from cwd */
63
26
  function findGitHeadPath(): string | null {
64
27
  let dir = process.cwd();
65
28
  while (true) {
@@ -69,43 +32,53 @@ function findGitHeadPath(): string | null {
69
32
  }
70
33
  const parent = dirname(dir);
71
34
  if (parent === dir) {
72
- // Reached filesystem root
73
35
  return null;
74
36
  }
75
37
  dir = parent;
76
38
  }
77
39
  }
78
40
 
79
- /**
80
- * Footer component that shows pwd, token stats, and context usage
81
- */
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // StatusLineComponent
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
82
45
  export class StatusLineComponent implements Component {
83
46
  private session: AgentSession;
84
- 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;
85
49
  private gitWatcher: FSWatcher | null = null;
86
50
  private onBranchChange: (() => void) | null = null;
87
51
  private autoCompactEnabled: boolean = true;
88
52
  private hookStatuses: Map<string, string> = new Map();
53
+ private subagentCount: number = 0;
54
+ private sessionStartTime: number = Date.now();
89
55
 
90
- // Git status caching (1s TTL to avoid excessive subprocess spawns)
56
+ // Git status caching (1s TTL)
91
57
  private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
92
58
  private gitStatusLastFetch = 0;
93
59
 
94
60
  constructor(session: AgentSession) {
95
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;
96
68
  }
97
69
 
98
70
  setAutoCompactEnabled(enabled: boolean): void {
99
71
  this.autoCompactEnabled = enabled;
100
72
  }
101
73
 
102
- /**
103
- * Set hook status text to display in the footer.
104
- * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width.
105
- * ANSI escape codes for styling are preserved.
106
- * @param key - Unique key to identify this status
107
- * @param text - Status text, or undefined to clear
108
- */
74
+ setSubagentCount(count: number): void {
75
+ this.subagentCount = count;
76
+ }
77
+
78
+ setSessionStartTime(time: number): void {
79
+ this.sessionStartTime = time;
80
+ }
81
+
109
82
  setHookStatus(key: string, text: string | undefined): void {
110
83
  if (text === undefined) {
111
84
  this.hookStatuses.delete(key);
@@ -114,42 +87,32 @@ export class StatusLineComponent implements Component {
114
87
  }
115
88
  }
116
89
 
117
- /**
118
- * Set up a file watcher on .git/HEAD to detect branch changes.
119
- * Call the provided callback when branch changes.
120
- */
121
90
  watchBranch(onBranchChange: () => void): void {
122
91
  this.onBranchChange = onBranchChange;
123
92
  this.setupGitWatcher();
124
93
  }
125
94
 
126
95
  private setupGitWatcher(): void {
127
- // Clean up existing watcher
128
96
  if (this.gitWatcher) {
129
97
  this.gitWatcher.close();
130
98
  this.gitWatcher = null;
131
99
  }
132
100
 
133
101
  const gitHeadPath = findGitHeadPath();
134
- if (!gitHeadPath) {
135
- return;
136
- }
102
+ if (!gitHeadPath) return;
137
103
 
138
104
  try {
139
105
  this.gitWatcher = watch(gitHeadPath, () => {
140
- this.cachedBranch = undefined; // Invalidate cache
106
+ this.cachedBranch = undefined;
141
107
  if (this.onBranchChange) {
142
108
  this.onBranchChange();
143
109
  }
144
110
  });
145
111
  } catch {
146
- // Silently fail if we can't watch
112
+ // Silently fail
147
113
  }
148
114
  }
149
115
 
150
- /**
151
- * Clean up the file watcher
152
- */
153
116
  dispose(): void {
154
117
  if (this.gitWatcher) {
155
118
  this.gitWatcher.close();
@@ -158,16 +121,10 @@ export class StatusLineComponent implements Component {
158
121
  }
159
122
 
160
123
  invalidate(): void {
161
- // Invalidate cached branch so it gets re-read on next render
162
124
  this.cachedBranch = undefined;
163
125
  }
164
126
 
165
- /**
166
- * Get current git branch by reading .git/HEAD directly.
167
- * Returns null if not in a git repo, branch name otherwise.
168
- */
169
127
  private getCurrentBranch(): string | null {
170
- // Return cached value if available
171
128
  if (this.cachedBranch !== undefined) {
172
129
  return this.cachedBranch;
173
130
  }
@@ -181,25 +138,17 @@ export class StatusLineComponent implements Component {
181
138
  const content = readFileSync(gitHeadPath, "utf8").trim();
182
139
 
183
140
  if (content.startsWith("ref: refs/heads/")) {
184
- // Normal branch: extract branch name
185
141
  this.cachedBranch = content.slice(16);
186
142
  } else {
187
- // Detached HEAD state
188
143
  this.cachedBranch = "detached";
189
144
  }
190
145
  } catch {
191
- // Not in a git repo or error reading file
192
146
  this.cachedBranch = null;
193
147
  }
194
148
 
195
149
  return this.cachedBranch;
196
150
  }
197
151
 
198
- /**
199
- * Get git status indicators (staged, unstaged, untracked counts).
200
- * Returns null if not in a git repo.
201
- * Cached for 1s to avoid excessive subprocess spawns.
202
- */
203
152
  private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
204
153
  const now = Date.now();
205
154
  if (now - this.gitStatusLastFetch < 1000) {
@@ -219,21 +168,18 @@ export class StatusLineComponent implements Component {
219
168
 
220
169
  for (const line of output.split("\n")) {
221
170
  if (!line) continue;
222
- const x = line[0]; // Index (staged) status
223
- const y = line[1]; // Working tree status
171
+ const x = line[0];
172
+ const y = line[1];
224
173
 
225
- // Untracked files
226
174
  if (x === "?" && y === "?") {
227
175
  untracked++;
228
176
  continue;
229
177
  }
230
178
 
231
- // Staged changes (first column is not space or ?)
232
179
  if (x && x !== " " && x !== "?") {
233
180
  staged++;
234
181
  }
235
182
 
236
- // Unstaged changes (second column is not space)
237
183
  if (y && y !== " ") {
238
184
  unstaged++;
239
185
  }
@@ -249,10 +195,19 @@ export class StatusLineComponent implements Component {
249
195
  }
250
196
  }
251
197
 
252
- private buildStatusLine(): string {
198
+ private buildSegmentContext(width: number): SegmentContext {
253
199
  const state = this.session.state;
254
200
 
255
- // Get context percentage from last assistant message
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
+ };
209
+
210
+ // Get context percentage
256
211
  const lastAssistantMessage = state.messages
257
212
  .slice()
258
213
  .reverse()
@@ -265,166 +220,158 @@ export class StatusLineComponent implements Component {
265
220
  lastAssistantMessage.usage.cacheWrite
266
221
  : 0;
267
222
  const contextWindow = state.model?.contextWindow || 0;
268
- const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
269
-
270
- // Format helpers
271
- const formatTokens = (n: number): string => {
272
- if (n < 1000) return n.toString();
273
- if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
274
- if (n < 1000000) return `${Math.round(n / 1000)}k`;
275
- if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
276
- return `${Math.round(n / 1000000)}M`;
277
- };
223
+ const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;
278
224
 
279
- // ═══════════════════════════════════════════════════════════════════════
280
- // SEGMENT 1: Model
281
- // ═══════════════════════════════════════════════════════════════════════
282
- let modelName = state.model?.name || state.model?.id || "no-model";
283
- // Strip "Claude " prefix for brevity
284
- if (modelName.startsWith("Claude ")) {
285
- modelName = modelName.slice(7);
286
- }
287
- let modelContent = `${ICONS.model} ${modelName}`;
288
- if (state.model?.reasoning) {
289
- const level = state.thinkingLevel || "off";
290
- if (level !== "off") {
291
- modelContent += ` · ${THINKING_ICONS[level] ?? level}`;
292
- }
293
- }
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
+ },
239
+ };
240
+ }
294
241
 
295
- // ═══════════════════════════════════════════════════════════════════════
296
- // SEGMENT 2: Path
297
- // ═══════════════════════════════════════════════════════════════════════
298
- let pwd = process.cwd();
299
- const home = process.env.HOME || process.env.USERPROFILE;
300
- if (home && pwd.startsWith(home)) {
301
- pwd = `~${pwd.slice(home.length)}`;
302
- }
303
- if (pwd.startsWith("/work/")) {
304
- pwd = pwd.slice(6);
305
- }
306
- const pathContent = `${ICONS.folder} ${pwd}`;
307
-
308
- // ═══════════════════════════════════════════════════════════════════════
309
- // SEGMENT 3: Git Branch + Status
310
- // ═══════════════════════════════════════════════════════════════════════
311
- const branch = this.getCurrentBranch();
312
- let gitContent = "";
313
- let gitColorName: "statusLineGitClean" | "statusLineGitDirty" = "statusLineGitClean";
314
- if (branch) {
315
- const gitStatus = this.getGitStatus();
316
- const isDirty = gitStatus && (gitStatus.staged > 0 || gitStatus.unstaged > 0 || gitStatus.untracked > 0);
317
- gitColorName = isDirty ? "statusLineGitDirty" : "statusLineGitClean";
318
-
319
- gitContent = `${ICONS.branch} ${branch}`;
320
-
321
- if (gitStatus) {
322
- const indicators: string[] = [];
323
- if (gitStatus.unstaged > 0) {
324
- indicators.push(theme.fg("statusLineDirty", `*${gitStatus.unstaged}`));
325
- }
326
- if (gitStatus.staged > 0) {
327
- indicators.push(theme.fg("statusLineStaged", `+${gitStatus.staged}`));
328
- }
329
- if (gitStatus.untracked > 0) {
330
- indicators.push(theme.fg("statusLineUntracked", `?${gitStatus.untracked}`));
331
- }
332
- if (indicators.length > 0) {
333
- gitContent += ` ${indicators.join(" ")}`;
334
- }
335
- }
336
- }
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"] = {};
337
249
 
338
- // ═══════════════════════════════════════════════════════════════════════
339
- // SEGMENT 4: Context (window usage)
340
- // ═══════════════════════════════════════════════════════════════════════
341
- const autoIndicator = this.autoCompactEnabled ? ` ${ICONS.auto}` : "";
342
- const contextText = `${contextPercentValue.toFixed(1)}%/${formatTokens(contextWindow)}${autoIndicator}`;
343
- let contextContent: string;
344
- if (contextPercentValue > 90) {
345
- contextContent = `${ICONS.context} ${theme.fg("error", contextText)}`;
346
- } else if (contextPercentValue > 70) {
347
- contextContent = `${ICONS.context} ${theme.fg("warning", contextText)}`;
348
- } else {
349
- contextContent = `${ICONS.context} ${contextText}`;
250
+ for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
251
+ mergedSegmentOptions[segment as keyof StatusLineSegmentOptions] = { ...(options as Record<string, unknown>) };
350
252
  }
351
253
 
352
- // ═══════════════════════════════════════════════════════════════════════
353
- // SEGMENT 5: Spend (tokens + cost)
354
- // ═══════════════════════════════════════════════════════════════════════
355
- const spendParts: string[] = [];
356
-
357
- const { input, output, cacheRead, cacheWrite, cost } = this.session.sessionManager.getUsageStatistics();
358
- const totalTokens = input + output + cacheRead + cacheWrite;
359
- if (totalTokens) {
360
- spendParts.push(`${ICONS.tokens} ${formatTokens(totalTokens)}`);
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
+ };
361
260
  }
362
261
 
363
- const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false;
364
- if (cost || usingSubscription) {
365
- const costDisplay = `$${cost.toFixed(2)}${usingSubscription ? " (sub)" : ""}`;
366
- spendParts.push(costDisplay);
367
- }
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
+ }
368
270
 
369
- const spendContent = theme.fg("statusLineCost", spendParts.join(" · "));
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);
370
275
 
371
- // ═══════════════════════════════════════════════════════════════════════
372
- // Assemble: [Model] > [Path] > [Git?] > [Context] > [Spend] >
373
- // ═══════════════════════════════════════════════════════════════════════
374
276
  const bgAnsi = theme.getBgAnsi("statusLineBg");
277
+ const fgAnsi = theme.getFgAnsi("text");
375
278
  const sepAnsi = theme.getFgAnsi("statusLineSep");
376
279
 
377
- let statusLine = "";
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
+ }
287
+ }
288
+
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
+ }
296
+
297
+ const topFillWidth = width > 0 ? Math.max(0, width - 4) : 0;
298
+ const left = [...leftParts];
299
+ const right = [...rightParts];
378
300
 
379
- // Pi segment
380
- statusLine += plSegment(`${ICONS.pi} `, theme.getFgAnsi("statusLineContext"), bgAnsi);
381
- statusLine += plSep(sepAnsi, bgAnsi);
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;
382
305
 
383
- // Model segment
384
- statusLine += plSegment(modelContent, theme.getFgAnsi("statusLineModel"), bgAnsi);
385
- 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
+ };
386
312
 
387
- // Path segment
388
- 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);
389
316
 
390
- if (gitContent) {
391
- statusLine += plSep(sepAnsi, bgAnsi);
392
- 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
+ }
393
326
  }
394
327
 
395
- // Context segment
396
- statusLine += plSep(sepAnsi, bgAnsi);
397
- 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
+ };
398
348
 
399
- // Spend segment
400
- statusLine += plSep(sepAnsi, bgAnsi);
401
- statusLine += plSegment(spendContent, theme.getFgAnsi("statusLineSpend"), bgAnsi);
349
+ const leftGroup = renderGroup(left, "left");
350
+ const rightGroup = renderGroup(right, "right");
351
+ if (!leftGroup && !rightGroup) return "";
402
352
 
403
- // End cap (solid arrow to terminal bg)
404
- statusLine += plEnd(bgAnsi);
353
+ if (topFillWidth === 0 || left.length === 0 || right.length === 0) {
354
+ return leftGroup + (leftGroup && rightGroup ? " " : "") + rightGroup;
355
+ }
405
356
 
406
- 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;
407
361
  }
408
362
 
409
- /**
410
- * Get the status line content for use as editor top border.
411
- * Returns the content string and its visible width.
412
- */
413
- getTopBorder(_width: number): { content: string; width: number } {
414
- const content = this.buildStatusLine();
363
+ getTopBorder(width: number): { content: string; width: number } {
364
+ const content = this.buildStatusLine(width);
415
365
  return {
416
366
  content,
417
367
  width: visibleWidth(content),
418
368
  };
419
369
  }
420
370
 
421
- /**
422
- * Render only hook statuses (if any).
423
- * Used when footer is integrated into editor border.
424
- */
425
371
  render(width: number): string[] {
426
372
  // Only render hook statuses - main status is in editor's top border
427
- if (this.hookStatuses.size === 0) {
373
+ const showHooks = this.settings.showHookStatus ?? true;
374
+ if (!showHooks || this.hookStatuses.size === 0) {
428
375
  return [];
429
376
  }
430
377
 
@@ -432,6 +379,6 @@ export class StatusLineComponent implements Component {
432
379
  .sort(([a], [b]) => a.localeCompare(b))
433
380
  .map(([, text]) => sanitizeStatusText(text));
434
381
  const hookLine = sortedStatuses.join(" ");
435
- return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", "…"))];
382
+ return [truncateToWidth(hookLine, width, theme.fg("statusLineSep", theme.format.ellipsis))];
436
383
  }
437
384
  }