@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/types/config/settings-schema.d.ts +34 -1
  3. package/dist/types/config/settings.d.ts +6 -0
  4. package/dist/types/discovery/helpers.d.ts +1 -0
  5. package/dist/types/goals/runtime.d.ts +4 -0
  6. package/dist/types/modes/components/status-line/types.d.ts +10 -0
  7. package/dist/types/modes/components/status-line.d.ts +10 -0
  8. package/dist/types/modes/interactive-mode.d.ts +3 -1
  9. package/dist/types/modes/types.d.ts +3 -1
  10. package/dist/types/modes/utils/context-usage.d.ts +17 -0
  11. package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/task/executor.d.ts +3 -1
  14. package/dist/types/task/types.d.ts +35 -0
  15. package/dist/types/tools/bash-command-fixup.d.ts +0 -5
  16. package/dist/types/utils/clipboard.d.ts +3 -1
  17. package/dist/types/utils/image-resize.d.ts +4 -1
  18. package/package.json +7 -7
  19. package/src/config/settings-schema.ts +29 -1
  20. package/src/config/settings.ts +19 -0
  21. package/src/discovery/helpers.ts +5 -1
  22. package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
  23. package/src/goals/runtime.ts +35 -13
  24. package/src/main.ts +1 -1
  25. package/src/modes/components/model-selector.ts +53 -22
  26. package/src/modes/components/status-line/segments.ts +53 -0
  27. package/src/modes/components/status-line/types.ts +4 -0
  28. package/src/modes/components/status-line.ts +147 -12
  29. package/src/modes/controllers/command-controller.ts +9 -0
  30. package/src/modes/controllers/event-controller.ts +8 -0
  31. package/src/modes/interactive-mode.ts +23 -8
  32. package/src/modes/theme/theme.ts +1 -1
  33. package/src/modes/types.ts +1 -1
  34. package/src/modes/utils/context-usage.ts +25 -2
  35. package/src/modes/utils/ui-helpers.ts +11 -1
  36. package/src/prompts/agents/frontmatter.md +1 -0
  37. package/src/sdk.ts +24 -0
  38. package/src/session/agent-session.ts +58 -0
  39. package/src/session/session-manager.ts +54 -1
  40. package/src/slash-commands/builtin-registry.ts +10 -0
  41. package/src/task/executor.ts +50 -1
  42. package/src/task/index.ts +11 -0
  43. package/src/task/render.ts +26 -2
  44. package/src/task/types.ts +35 -0
  45. package/src/tools/bash-command-fixup.ts +0 -10
  46. package/src/tools/bash.ts +1 -9
  47. package/src/utils/clipboard.ts +68 -3
  48. package/src/utils/image-resize.ts +51 -26
  49. package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
  50. package/src/modes/components/status-line-segment-editor.ts +0 -359
@@ -5,6 +5,7 @@ export interface ImageResizeOptions {
5
5
  maxHeight?: number;
6
6
  maxBytes?: number;
7
7
  jpegQuality?: number;
8
+ excludeWebP?: boolean;
8
9
  }
9
10
 
10
11
  export interface ResizedImage {
@@ -29,6 +30,7 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
29
30
  maxHeight: 1568,
30
31
  maxBytes: DEFAULT_MAX_BYTES,
31
32
  jpegQuality: 80,
33
+ excludeWebP: Bun.env.OMP_NO_WEBP !== undefined,
32
34
  };
33
35
 
34
36
  /** Pick the smallest of N encoded buffers. */
@@ -49,11 +51,13 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
49
51
  *
50
52
  * Strategy:
51
53
  * 1. Probe metadata. If already within all limits, return original.
52
- * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG/WebP — return smallest.
54
+ * 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG (+ WebP) — return smallest.
53
55
  * 3. If still too large, walk a lossy JPEG/WebP quality ladder.
54
56
  * 4. If still too large, walk a dimension-scale ladder × quality ladder.
55
57
  * 5. If still too large, return the smallest variant produced.
56
58
  *
59
+ * Set OMP_NO_WEBP to exclude WebP from encoding (llama.cpp STB doesn't decode it).
60
+ *
57
61
  * Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
58
62
  * off the JS thread when the terminal (`.bytes()`) is awaited.
59
63
  */
@@ -99,44 +103,65 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
99
103
  targetHeight = opts.maxHeight;
100
104
  }
101
105
 
102
- // First-attempt encoder: try PNG, JPEG, and lossy WebP — return whichever is smallest.
103
- // PNG wins for line art / few-color UI; JPEG and WebP win for photographic content;
104
- // WebP usually beats JPEG by 25–35% at the same perceptual quality.
106
+ // First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
107
+ // PNG wins for line art / few-color UI; JPEG wins for photographic content;
108
+ // WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
109
+ // because many local inference backends (llama.cpp STB) don't decode it.
105
110
  async function encodeSmallest(
106
111
  width: number,
107
112
  height: number,
108
113
  quality: number,
109
114
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
110
- const [pngBuffer, jpegBuffer, webpBuffer] = await Promise.all([
111
- new Bun.Image(inputBuffer).resize(width, height).png().bytes(),
112
- new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
113
- new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
115
+ const candidates = await Promise.all([
116
+ new Bun.Image(inputBuffer)
117
+ .resize(width, height)
118
+ .png()
119
+ .bytes()
120
+ .then(b => ({ buffer: b, mimeType: "image/png" })),
121
+ new Bun.Image(inputBuffer)
122
+ .resize(width, height)
123
+ .jpeg({ quality })
124
+ .bytes()
125
+ .then(b => ({ buffer: b, mimeType: "image/jpeg" })),
126
+ ...(opts.excludeWebP
127
+ ? []
128
+ : [
129
+ new Bun.Image(inputBuffer)
130
+ .resize(width, height)
131
+ .webp({ quality })
132
+ .bytes()
133
+ .then(b => ({ buffer: b, mimeType: "image/webp" })),
134
+ ]),
114
135
  ]);
115
- return pickSmallest(
116
- { buffer: pngBuffer, mimeType: "image/png" },
117
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
118
- { buffer: webpBuffer, mimeType: "image/webp" },
119
- );
136
+ return pickSmallest(...candidates);
120
137
  }
121
138
 
122
- // Lossy-only encoder used in quality/dimension fallback ladders where PNG can't shrink
123
- // further (PNG quality is a no-op). Picks the smaller of JPEG vs lossy WebP at the
124
- // requested quality.
139
+ // Lossy encoder for quality/dimension fallback ladders. PNG is excluded since
140
+ // it's lossless and doesn't respond to quality parameters. WebP is included
141
+ // unless OMP_NO_WEBP is set (llama.cpp STB incompatibility).
125
142
  async function encodeLossy(
126
143
  width: number,
127
144
  height: number,
128
145
  quality: number,
129
146
  ): Promise<{ buffer: Uint8Array; mimeType: string }> {
130
- const [jpegBuffer, webpBuffer] = await Promise.all([
131
- new Bun.Image(inputBuffer).resize(width, height).jpeg({ quality }).bytes(),
132
- new Bun.Image(inputBuffer).resize(width, height).webp({ quality }).bytes(),
147
+ const candidates = await Promise.all([
148
+ new Bun.Image(inputBuffer)
149
+ .resize(width, height)
150
+ .jpeg({ quality })
151
+ .bytes()
152
+ .then(b => ({ buffer: b, mimeType: "image/jpeg" })),
153
+ ...(opts.excludeWebP
154
+ ? []
155
+ : [
156
+ new Bun.Image(inputBuffer)
157
+ .resize(width, height)
158
+ .webp({ quality })
159
+ .bytes()
160
+ .then(b => ({ buffer: b, mimeType: "image/webp" })),
161
+ ]),
133
162
  ]);
134
- return pickSmallest(
135
- { buffer: jpegBuffer, mimeType: "image/jpeg" },
136
- { buffer: webpBuffer, mimeType: "image/webp" },
137
- );
163
+ return pickSmallest(...candidates);
138
164
  }
139
-
140
165
  // Quality ladder — more aggressive steps for tighter budgets
141
166
  const qualitySteps = [70, 60, 50, 40];
142
167
  const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
@@ -145,7 +170,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
145
170
  let finalWidth = targetWidth;
146
171
  let finalHeight = targetHeight;
147
172
 
148
- // First attempt: resize to target, try PNG/JPEG/WebP, pick smallest
173
+ // First attempt: resize to target, try PNG/JPEG (+ WebP), pick smallest
149
174
  best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
150
175
 
151
176
  if (best.buffer.length <= opts.maxBytes) {
@@ -163,7 +188,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
163
188
  };
164
189
  }
165
190
 
166
- // Still too large — lossy ladder (JPEG vs WebP, smallest wins) with decreasing quality
191
+ // Still too large — lossy JPEG (+ WebP) ladder with decreasing quality
167
192
  for (const quality of qualitySteps) {
168
193
  best = await encodeLossy(targetWidth, targetHeight, quality);
169
194
 
@@ -1,24 +0,0 @@
1
- /**
2
- * Status Line Segment Editor
3
- *
4
- * Interactive component for configuring status line segments.
5
- * - Three-column layout: Left | Right | Disabled
6
- * - Space: Toggle segment visibility (disabled ↔ left)
7
- * - Tab: Cycle segment between columns (left → right → disabled → left)
8
- * - Shift+J/K: Reorder segment within column
9
- * - Live preview shown in the actual status line above
10
- */
11
- import { Container } from "@oh-my-pi/pi-tui";
12
- import type { StatusLineSegmentId } from "../../config/settings-schema";
13
- export interface SegmentEditorCallbacks {
14
- onSave: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
15
- onCancel: () => void;
16
- onPreview?: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
17
- }
18
- export declare class StatusLineSegmentEditorComponent extends Container {
19
- #private;
20
- private readonly callbacks;
21
- constructor(currentLeft: StatusLineSegmentId[], currentRight: StatusLineSegmentId[], callbacks: SegmentEditorCallbacks);
22
- handleInput(data: string): void;
23
- render(width: number): string[];
24
- }
@@ -1,359 +0,0 @@
1
- /**
2
- * Status Line Segment Editor
3
- *
4
- * Interactive component for configuring status line segments.
5
- * - Three-column layout: Left | Right | Disabled
6
- * - Space: Toggle segment visibility (disabled ↔ left)
7
- * - Tab: Cycle segment between columns (left → right → disabled → left)
8
- * - Shift+J/K: Reorder segment within column
9
- * - Live preview shown in the actual status line above
10
- */
11
- import { Container, matchesKey, padding } from "@oh-my-pi/pi-tui";
12
- import type { StatusLineSegmentId } from "../../config/settings-schema";
13
- import { theme } from "../../modes/theme/theme";
14
- import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
15
- import { ALL_SEGMENT_IDS } from "./status-line/segments";
16
-
17
- // Segment display names and short descriptions
18
- const SEGMENT_INFO: Record<StatusLineSegmentId, { label: string; short: string }> = {
19
- pi: { label: "Pi", short: "π icon" },
20
- model: { label: "Model", short: "model name" },
21
- mode: { label: "Mode", short: "plan/loop status" },
22
- path: { label: "Path", short: "working dir" },
23
- git: { label: "Git", short: "branch/status" },
24
- pr: { label: "PR", short: "pull request" },
25
- subagents: { label: "Agents", short: "subagent count" },
26
- token_in: { label: "Tokens In", short: "input tokens" },
27
- token_out: { label: "Tokens Out", short: "output tokens" },
28
- token_total: { label: "Tokens", short: "total tokens" },
29
- token_rate: { label: "Tokens/s", short: "output throughput" },
30
- cost: { label: "Cost", short: "session cost" },
31
- context_pct: { label: "Context %", short: "context usage" },
32
- context_total: { label: "Context", short: "context window" },
33
- time_spent: { label: "Elapsed", short: "session time" },
34
- time: { label: "Clock", short: "current time" },
35
- session: { label: "Session", short: "session ID" },
36
- hostname: { label: "Host", short: "hostname" },
37
- cache_read: { label: "Cache ↑", short: "cache read" },
38
- cache_write: { label: "Cache ↓", short: "cache write" },
39
- session_name: { label: "Session Name", short: "named session" },
40
- };
41
-
42
- type Column = "left" | "right" | "disabled";
43
-
44
- interface SegmentState {
45
- id: StatusLineSegmentId;
46
- column: Column;
47
- order: number;
48
- }
49
-
50
- export interface SegmentEditorCallbacks {
51
- onSave: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
52
- onCancel: () => void;
53
- onPreview?: (leftSegments: StatusLineSegmentId[], rightSegments: StatusLineSegmentId[]) => void;
54
- }
55
-
56
- export class StatusLineSegmentEditorComponent extends Container {
57
- #segments: SegmentState[];
58
- #selectedIndex: number = 0;
59
- #focusColumn: "left" | "right" | "disabled" = "left";
60
-
61
- constructor(
62
- currentLeft: StatusLineSegmentId[],
63
- currentRight: StatusLineSegmentId[],
64
- private readonly callbacks: SegmentEditorCallbacks,
65
- ) {
66
- super();
67
-
68
- // Initialize segment states
69
- this.#segments = [];
70
- const usedIds = new Set<StatusLineSegmentId>();
71
-
72
- // Add left segments in order
73
- for (let i = 0; i < currentLeft.length; i++) {
74
- const id = currentLeft[i];
75
- this.#segments.push({ id, column: "left", order: i });
76
- usedIds.add(id);
77
- }
78
-
79
- // Add right segments in order
80
- for (let i = 0; i < currentRight.length; i++) {
81
- const id = currentRight[i];
82
- this.#segments.push({ id, column: "right", order: i });
83
- usedIds.add(id);
84
- }
85
-
86
- // Add remaining segments as disabled
87
- for (const id of ALL_SEGMENT_IDS) {
88
- if (!usedIds.has(id)) {
89
- this.#segments.push({ id, column: "disabled", order: 999 });
90
- }
91
- }
92
-
93
- // Trigger initial preview
94
- this.#triggerPreview();
95
- }
96
-
97
- #getSegmentsForColumn(column: Column): SegmentState[] {
98
- return this.#segments.filter(s => s.column === column).sort((a, b) => a.order - b.order);
99
- }
100
-
101
- #getCurrentColumnSegments(): SegmentState[] {
102
- return this.#getSegmentsForColumn(this.#focusColumn);
103
- }
104
-
105
- #triggerPreview(): void {
106
- const left = this.#getSegmentsForColumn("left").map(s => s.id);
107
- const right = this.#getSegmentsForColumn("right").map(s => s.id);
108
- this.callbacks.onPreview?.(left, right);
109
- }
110
-
111
- handleInput(data: string): void {
112
- const columnSegments = this.#getCurrentColumnSegments();
113
-
114
- if (matchesKey(data, "up") || data === "k") {
115
- // Move selection up within column, or jump to previous column
116
- if (this.#selectedIndex > 0) {
117
- this.#selectedIndex--;
118
- } else {
119
- // Jump to previous column
120
- if (this.#focusColumn === "disabled") {
121
- const rightSegs = this.#getSegmentsForColumn("right");
122
- if (rightSegs.length > 0) {
123
- this.#focusColumn = "right";
124
- this.#selectedIndex = rightSegs.length - 1;
125
- } else {
126
- const leftSegs = this.#getSegmentsForColumn("left");
127
- if (leftSegs.length > 0) {
128
- this.#focusColumn = "left";
129
- this.#selectedIndex = leftSegs.length - 1;
130
- }
131
- }
132
- } else if (this.#focusColumn === "right") {
133
- const leftSegs = this.#getSegmentsForColumn("left");
134
- if (leftSegs.length > 0) {
135
- this.#focusColumn = "left";
136
- this.#selectedIndex = leftSegs.length - 1;
137
- }
138
- }
139
- }
140
- } else if (matchesKey(data, "down") || data === "j") {
141
- // Move selection down within column, or jump to next column
142
- if (this.#selectedIndex < columnSegments.length - 1) {
143
- this.#selectedIndex++;
144
- } else {
145
- // Jump to next column
146
- if (this.#focusColumn === "left") {
147
- const rightSegs = this.#getSegmentsForColumn("right");
148
- if (rightSegs.length > 0) {
149
- this.#focusColumn = "right";
150
- this.#selectedIndex = 0;
151
- } else {
152
- const disabledSegs = this.#getSegmentsForColumn("disabled");
153
- if (disabledSegs.length > 0) {
154
- this.#focusColumn = "disabled";
155
- this.#selectedIndex = 0;
156
- }
157
- }
158
- } else if (this.#focusColumn === "right") {
159
- const disabledSegs = this.#getSegmentsForColumn("disabled");
160
- if (disabledSegs.length > 0) {
161
- this.#focusColumn = "disabled";
162
- this.#selectedIndex = 0;
163
- }
164
- }
165
- }
166
- } else if (matchesKey(data, "tab")) {
167
- // Cycle segment: left → right → disabled → left
168
- const seg = columnSegments[this.#selectedIndex];
169
- if (seg) {
170
- const oldColumn = seg.column;
171
- if (seg.column === "left") {
172
- seg.column = "right";
173
- seg.order = this.#getSegmentsForColumn("right").length;
174
- } else if (seg.column === "right") {
175
- seg.column = "disabled";
176
- seg.order = 999;
177
- } else {
178
- seg.column = "left";
179
- seg.order = this.#getSegmentsForColumn("left").length;
180
- }
181
- // Recompact orders in old column
182
- this.#recompactColumn(oldColumn);
183
- this.#triggerPreview();
184
- }
185
- } else if (matchesKey(data, "shift+tab")) {
186
- // Reverse cycle: left ← right ← disabled ← left
187
- const seg = columnSegments[this.#selectedIndex];
188
- if (seg) {
189
- const oldColumn = seg.column;
190
- if (seg.column === "left") {
191
- seg.column = "disabled";
192
- seg.order = 999;
193
- } else if (seg.column === "right") {
194
- seg.column = "left";
195
- seg.order = this.#getSegmentsForColumn("left").length;
196
- } else {
197
- seg.column = "right";
198
- seg.order = this.#getSegmentsForColumn("right").length;
199
- }
200
- this.#recompactColumn(oldColumn);
201
- this.#triggerPreview();
202
- }
203
- } else if (data === " ") {
204
- // Quick toggle: disabled ↔ left
205
- const seg = columnSegments[this.#selectedIndex];
206
- if (seg) {
207
- const oldColumn = seg.column;
208
- if (seg.column === "disabled") {
209
- seg.column = "left";
210
- seg.order = this.#getSegmentsForColumn("left").length;
211
- } else {
212
- seg.column = "disabled";
213
- seg.order = 999;
214
- }
215
- this.#recompactColumn(oldColumn);
216
- this.#triggerPreview();
217
- }
218
- } else if (data === "K") {
219
- // Move segment up in order (Shift+K)
220
- const seg = columnSegments[this.#selectedIndex];
221
- if (seg && seg.column !== "disabled" && this.#selectedIndex > 0) {
222
- const prevSeg = columnSegments[this.#selectedIndex - 1];
223
- const tempOrder = seg.order;
224
- seg.order = prevSeg.order;
225
- prevSeg.order = tempOrder;
226
- this.#selectedIndex--;
227
- this.#triggerPreview();
228
- }
229
- } else if (data === "J") {
230
- // Move segment down in order (Shift+J)
231
- const seg = columnSegments[this.#selectedIndex];
232
- if (seg && seg.column !== "disabled" && this.#selectedIndex < columnSegments.length - 1) {
233
- const nextSeg = columnSegments[this.#selectedIndex + 1];
234
- const tempOrder = seg.order;
235
- seg.order = nextSeg.order;
236
- nextSeg.order = tempOrder;
237
- this.#selectedIndex++;
238
- this.#triggerPreview();
239
- }
240
- } else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
241
- const left = this.#getSegmentsForColumn("left").map(s => s.id);
242
- const right = this.#getSegmentsForColumn("right").map(s => s.id);
243
- this.callbacks.onSave(left, right);
244
- } else if (matchesAppInterrupt(data)) {
245
- this.callbacks.onCancel();
246
- }
247
- }
248
-
249
- #recompactColumn(column: Column): void {
250
- if (column === "disabled") return;
251
- const segs = this.#getSegmentsForColumn(column);
252
- for (let i = 0; i < segs.length; i++) {
253
- segs[i].order = i;
254
- }
255
- }
256
-
257
- render(width: number): string[] {
258
- const lines: string[] = [];
259
-
260
- // Title with live preview indicator
261
- lines.push(theme.bold(theme.fg("accent", "Configure Status Line Segments")));
262
- lines.push(theme.fg("dim", "Live preview shown in status line above"));
263
- lines.push("");
264
-
265
- // Key bindings
266
- lines.push(
267
- theme.fg("muted", "Space") +
268
- " toggle " +
269
- theme.fg("muted", "Tab/S-Tab") +
270
- " cycle column " +
271
- theme.fg("muted", "J/K") +
272
- " reorder " +
273
- theme.fg("muted", "Enter") +
274
- " save " +
275
- theme.fg("muted", "Esc") +
276
- " cancel",
277
- );
278
- lines.push("");
279
-
280
- // Get segments for each column
281
- const leftSegs = this.#getSegmentsForColumn("left");
282
- const rightSegs = this.#getSegmentsForColumn("right");
283
- const disabledSegs = this.#getSegmentsForColumn("disabled");
284
-
285
- // Calculate column widths
286
- const colWidth = Math.max(18, Math.floor((width - 6) / 3));
287
-
288
- // Column headers
289
- const activeMarker = theme.nav.back;
290
- const leftHeader =
291
- this.#focusColumn === "left"
292
- ? theme.bold(theme.fg("accent", `${activeMarker} LEFT`))
293
- : theme.fg("muted", " LEFT");
294
- const rightHeader =
295
- this.#focusColumn === "right"
296
- ? theme.bold(theme.fg("accent", `${activeMarker} RIGHT`))
297
- : theme.fg("muted", " RIGHT");
298
- const disabledHeader =
299
- this.#focusColumn === "disabled"
300
- ? theme.bold(theme.fg("accent", `${activeMarker} AVAILABLE`))
301
- : theme.fg("muted", " AVAILABLE");
302
-
303
- lines.push(`${leftHeader.padEnd(colWidth + 8)}${rightHeader.padEnd(colWidth + 8)}${disabledHeader}`);
304
- lines.push(theme.fg("dim", theme.boxRound.horizontal.repeat(Math.min(width - 2, colWidth * 3 + 6))));
305
-
306
- // Render rows
307
- const maxRows = Math.max(leftSegs.length, rightSegs.length, disabledSegs.length, 1);
308
-
309
- for (let row = 0; row < maxRows; row++) {
310
- let line = "";
311
-
312
- // Left column
313
- line += this.#renderSegmentCell(leftSegs[row], "left", row, colWidth);
314
-
315
- // Right column
316
- line += this.#renderSegmentCell(rightSegs[row], "right", row, colWidth);
317
-
318
- // Disabled column
319
- line += this.#renderSegmentCell(disabledSegs[row], "disabled", row, colWidth);
320
-
321
- lines.push(line);
322
- }
323
-
324
- // Summary line
325
- lines.push("");
326
- const leftCount = leftSegs.length;
327
- const rightCount = rightSegs.length;
328
- const summary = theme.fg(
329
- "dim",
330
- `${leftCount} left ${theme.sep.dot} ${rightCount} right ${theme.sep.dot} ${disabledSegs.length} available`,
331
- );
332
- lines.push(summary);
333
-
334
- return lines;
335
- }
336
-
337
- #renderSegmentCell(seg: SegmentState | undefined, column: Column, row: number, colWidth: number): string {
338
- if (!seg) {
339
- return "".padEnd(colWidth + 2);
340
- }
341
-
342
- const isSelected = this.#focusColumn === column && this.#selectedIndex === row;
343
- const info = SEGMENT_INFO[seg.id];
344
- const label = info?.label ?? seg.id;
345
-
346
- let text: string;
347
- if (isSelected) {
348
- text = theme.bg("selectedBg", theme.fg("text", ` ${label} `));
349
- } else if (column === "disabled") {
350
- text = theme.fg("dim", ` ${label}`);
351
- } else {
352
- text = theme.fg("text", ` ${label}`);
353
- }
354
-
355
- // Pad to column width (accounting for ANSI codes)
356
- const padSize = colWidth - label.length - 1;
357
- return text + padding(Math.max(0, padSize));
358
- }
359
- }