@oh-my-pi/pi-coding-agent 15.2.4 → 15.3.1
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.
- package/CHANGELOG.md +20 -0
- package/dist/types/config/model-registry.d.ts +26 -0
- package/dist/types/config/settings-schema.d.ts +34 -1
- package/dist/types/config/settings.d.ts +6 -0
- package/dist/types/discovery/helpers.d.ts +1 -0
- package/dist/types/goals/runtime.d.ts +4 -0
- package/dist/types/modes/components/status-line/types.d.ts +10 -0
- package/dist/types/modes/components/status-line.d.ts +16 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -1
- package/dist/types/modes/types.d.ts +3 -1
- package/dist/types/modes/utils/context-usage.d.ts +17 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/session-manager.d.ts +10 -0
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/types.d.ts +35 -0
- package/dist/types/tools/bash-command-fixup.d.ts +0 -5
- package/dist/types/utils/clipboard.d.ts +3 -1
- package/dist/types/utils/image-resize.d.ts +4 -1
- package/package.json +7 -7
- package/src/config/model-registry.ts +46 -21
- package/src/config/settings-schema.ts +29 -1
- package/src/config/settings.ts +19 -0
- package/src/discovery/helpers.ts +5 -1
- package/src/extensibility/plugins/legacy-pi-compat.ts +27 -5
- package/src/goals/runtime.ts +35 -13
- package/src/hashline/parser.ts +6 -1
- package/src/internal-urls/docs-index.generated.ts +2 -1
- package/src/main.ts +1 -1
- package/src/modes/components/model-selector.ts +53 -22
- package/src/modes/components/status-line/segments.ts +53 -0
- package/src/modes/components/status-line/types.ts +4 -0
- package/src/modes/components/status-line.ts +243 -15
- package/src/modes/controllers/command-controller.ts +9 -0
- package/src/modes/controllers/event-controller.ts +8 -0
- package/src/modes/interactive-mode.ts +23 -8
- package/src/modes/theme/theme.ts +1 -1
- package/src/modes/types.ts +1 -1
- package/src/modes/utils/context-usage.ts +42 -8
- package/src/modes/utils/ui-helpers.ts +11 -1
- package/src/prompts/agents/frontmatter.md +1 -0
- package/src/sdk.ts +24 -0
- package/src/session/agent-session.ts +70 -0
- package/src/session/session-manager.ts +119 -1
- package/src/slash-commands/builtin-registry.ts +15 -0
- package/src/task/executor.ts +50 -1
- package/src/task/index.ts +11 -0
- package/src/task/render.ts +26 -2
- package/src/task/types.ts +35 -0
- package/src/tools/bash-command-fixup.ts +0 -10
- package/src/tools/bash.ts +1 -9
- package/src/utils/clipboard.ts +79 -3
- package/src/utils/image-resize.ts +78 -30
- package/dist/types/modes/components/status-line-segment-editor.d.ts +0 -24
- package/src/modes/components/status-line-segment-editor.ts +0 -359
package/src/utils/clipboard.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
|
3
3
|
import * as native from "@oh-my-pi/pi-natives";
|
|
4
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
function hasDisplay(): boolean {
|
|
7
|
+
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isWsl(): boolean {
|
|
11
|
+
return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
|
12
|
+
}
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Copy text to the system clipboard.
|
|
@@ -59,11 +66,72 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|
|
59
66
|
}
|
|
60
67
|
}
|
|
61
68
|
|
|
69
|
+
// PowerShell one-liner that emits the clipboard image as base64-encoded PNG on
|
|
70
|
+
// stdout, or nothing when the clipboard does not hold image data. Used as the
|
|
71
|
+
// WSL bridge — arboard cannot read the Windows clipboard through WSLg.
|
|
72
|
+
const POWERSHELL_IMAGE_SCRIPT = `
|
|
73
|
+
$ErrorActionPreference = 'Stop'
|
|
74
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
75
|
+
Add-Type -AssemblyName System.Drawing
|
|
76
|
+
$img = [System.Windows.Forms.Clipboard]::GetImage()
|
|
77
|
+
if ($img -ne $null) {
|
|
78
|
+
$ms = New-Object System.IO.MemoryStream
|
|
79
|
+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
80
|
+
[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const POWERSHELL_TIMEOUT_MS = 8000;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read a clipboard image through the Windows host's PowerShell.
|
|
88
|
+
*
|
|
89
|
+
* WSLg exposes a Wayland socket but no native clipboard image transport, so
|
|
90
|
+
* `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop,
|
|
91
|
+
* can read the Windows clipboard directly and round-trip the bitmap as PNG.
|
|
92
|
+
*
|
|
93
|
+
* Returns null when no image is on the clipboard, the host PowerShell is
|
|
94
|
+
* missing, or the bridge times out.
|
|
95
|
+
*/
|
|
96
|
+
async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
|
|
97
|
+
try {
|
|
98
|
+
const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], {
|
|
99
|
+
stdout: "pipe",
|
|
100
|
+
stderr: "ignore",
|
|
101
|
+
stdin: "ignore",
|
|
102
|
+
});
|
|
103
|
+
const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
|
|
104
|
+
let stdout = "";
|
|
105
|
+
try {
|
|
106
|
+
stdout = await new Response(proc.stdout).text();
|
|
107
|
+
await proc.exited;
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// powershell.exe is a Windows process reached over WSL interop; if it
|
|
110
|
+
// doesn't reap cleanly, swallow the error so the dispatcher can fall
|
|
111
|
+
// through to the native bridge instead of throwing.
|
|
112
|
+
logger.warn("clipboard: powershell read failed", { error: String(err) });
|
|
113
|
+
return null;
|
|
114
|
+
} finally {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
}
|
|
117
|
+
if (proc.exitCode !== 0) return null;
|
|
118
|
+
const b64 = stdout.trim();
|
|
119
|
+
if (!b64) return null;
|
|
120
|
+
const bytes = Buffer.from(b64, "base64");
|
|
121
|
+
if (bytes.byteLength === 0) return null;
|
|
122
|
+
return { data: new Uint8Array(bytes), mimeType: "image/png" };
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
62
128
|
/**
|
|
63
129
|
* Read an image from the system clipboard.
|
|
64
130
|
*
|
|
65
131
|
* Returns null on Termux (no image clipboard support) or when no display
|
|
66
|
-
* server is available (headless/SSH without forwarding).
|
|
132
|
+
* server is available (headless/SSH without forwarding). Under WSL the
|
|
133
|
+
* Windows clipboard is reached through `powershell.exe`, since WSLg's
|
|
134
|
+
* Wayland clipboard does not carry image payloads through to `arboard`.
|
|
67
135
|
*
|
|
68
136
|
* @returns PNG payload or null when no image is available.
|
|
69
137
|
*/
|
|
@@ -72,7 +140,15 @@ export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
|
|
|
72
140
|
return null;
|
|
73
141
|
}
|
|
74
142
|
|
|
75
|
-
if (
|
|
143
|
+
if (isWsl()) {
|
|
144
|
+
const image = await readImageViaPowerShell();
|
|
145
|
+
if (image) return image;
|
|
146
|
+
// Fall through: arboard may still succeed on a future WSLg release —
|
|
147
|
+
// but only when we actually have a display server. Headless WSL has
|
|
148
|
+
// no display, so arboard would reject anyway.
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!hasDisplay()) {
|
|
76
152
|
return null;
|
|
77
153
|
}
|
|
78
154
|
|
|
@@ -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 {
|
|
@@ -22,7 +23,7 @@ export interface ResizedImage {
|
|
|
22
23
|
// binding constraint once images are downsized to 1568px (Anthropic's internal threshold).
|
|
23
24
|
const DEFAULT_MAX_BYTES = 500 * 1024;
|
|
24
25
|
|
|
25
|
-
const DEFAULT_OPTIONS: Required<ImageResizeOptions
|
|
26
|
+
const DEFAULT_OPTIONS: Required<Omit<ImageResizeOptions, "excludeWebP">> = {
|
|
26
27
|
// Anthropic's "internal recommended size" — Claude internally caps images at
|
|
27
28
|
// 1568px on the longest edge before vision processing.
|
|
28
29
|
maxWidth: 1568,
|
|
@@ -31,6 +32,18 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
|
|
31
32
|
jpegQuality: 80,
|
|
32
33
|
};
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Read `OMP_NO_WEBP` per-call so runtime toggles take effect.
|
|
37
|
+
* Only `"1"` and `"true"` (case-insensitive) enable exclusion — an empty string
|
|
38
|
+
* or `"0"` MUST be treated as disabled.
|
|
39
|
+
*/
|
|
40
|
+
function isWebPExcluded(): boolean {
|
|
41
|
+
const raw = Bun.env.OMP_NO_WEBP;
|
|
42
|
+
if (raw === undefined) return false;
|
|
43
|
+
const v = raw.toLowerCase();
|
|
44
|
+
return v === "1" || v === "true";
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/** Pick the smallest of N encoded buffers. */
|
|
35
48
|
function pickSmallest(...candidates: Array<{ buffer: Uint8Array; mimeType: string }>): {
|
|
36
49
|
buffer: Uint8Array;
|
|
@@ -49,16 +62,19 @@ Buffer.prototype.toBase64 = function (this: Buffer) {
|
|
|
49
62
|
*
|
|
50
63
|
* Strategy:
|
|
51
64
|
* 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
|
|
65
|
+
* 2. Resize to fit max dimensions and encode at high quality across PNG/JPEG (+ WebP) — return smallest.
|
|
53
66
|
* 3. If still too large, walk a lossy JPEG/WebP quality ladder.
|
|
54
67
|
* 4. If still too large, walk a dimension-scale ladder × quality ladder.
|
|
55
68
|
* 5. If still too large, return the smallest variant produced.
|
|
56
69
|
*
|
|
70
|
+
* Set OMP_NO_WEBP to exclude WebP from encoding (llama.cpp STB doesn't decode it).
|
|
71
|
+
*
|
|
57
72
|
* Backed by `Bun.Image`: a chainable native pipeline that runs decode/transform/encode
|
|
58
73
|
* off the JS thread when the terminal (`.bytes()`) is awaited.
|
|
59
74
|
*/
|
|
60
75
|
export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {
|
|
61
|
-
const
|
|
76
|
+
const excludeWebP = options?.excludeWebP ?? isWebPExcluded();
|
|
77
|
+
const opts = { ...DEFAULT_OPTIONS, ...options, excludeWebP };
|
|
62
78
|
const inputBuffer = Buffer.from(img.data, "base64");
|
|
63
79
|
|
|
64
80
|
try {
|
|
@@ -71,7 +87,12 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
71
87
|
// still get JPEG-compressed.
|
|
72
88
|
const originalSize = inputBuffer.length;
|
|
73
89
|
const comfortableSize = opts.maxBytes / 4;
|
|
74
|
-
if (
|
|
90
|
+
if (
|
|
91
|
+
originalWidth <= opts.maxWidth &&
|
|
92
|
+
originalHeight <= opts.maxHeight &&
|
|
93
|
+
originalSize <= comfortableSize &&
|
|
94
|
+
!(opts.excludeWebP && sourceMime === "image/webp")
|
|
95
|
+
) {
|
|
75
96
|
return {
|
|
76
97
|
buffer: inputBuffer,
|
|
77
98
|
mimeType: sourceMime,
|
|
@@ -99,44 +120,65 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
99
120
|
targetHeight = opts.maxHeight;
|
|
100
121
|
}
|
|
101
122
|
|
|
102
|
-
// First-attempt encoder: try PNG
|
|
103
|
-
// PNG wins for line art / few-color UI; JPEG
|
|
104
|
-
// WebP usually beats JPEG by 25–35%
|
|
123
|
+
// First-attempt encoder: try PNG and JPEG (+ WebP if not excluded) — return smallest.
|
|
124
|
+
// PNG wins for line art / few-color UI; JPEG wins for photographic content;
|
|
125
|
+
// WebP usually beats JPEG by 25–35% but is disabled when OMP_NO_WEBP is set
|
|
126
|
+
// because many local inference backends (llama.cpp STB) don't decode it.
|
|
105
127
|
async function encodeSmallest(
|
|
106
128
|
width: number,
|
|
107
129
|
height: number,
|
|
108
130
|
quality: number,
|
|
109
131
|
): Promise<{ buffer: Uint8Array; mimeType: string }> {
|
|
110
|
-
const
|
|
111
|
-
new Bun.Image(inputBuffer)
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
const candidates = await Promise.all([
|
|
133
|
+
new Bun.Image(inputBuffer)
|
|
134
|
+
.resize(width, height)
|
|
135
|
+
.png()
|
|
136
|
+
.bytes()
|
|
137
|
+
.then(b => ({ buffer: b, mimeType: "image/png" })),
|
|
138
|
+
new Bun.Image(inputBuffer)
|
|
139
|
+
.resize(width, height)
|
|
140
|
+
.jpeg({ quality })
|
|
141
|
+
.bytes()
|
|
142
|
+
.then(b => ({ buffer: b, mimeType: "image/jpeg" })),
|
|
143
|
+
...(opts.excludeWebP
|
|
144
|
+
? []
|
|
145
|
+
: [
|
|
146
|
+
new Bun.Image(inputBuffer)
|
|
147
|
+
.resize(width, height)
|
|
148
|
+
.webp({ quality })
|
|
149
|
+
.bytes()
|
|
150
|
+
.then(b => ({ buffer: b, mimeType: "image/webp" })),
|
|
151
|
+
]),
|
|
114
152
|
]);
|
|
115
|
-
return pickSmallest(
|
|
116
|
-
{ buffer: pngBuffer, mimeType: "image/png" },
|
|
117
|
-
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
|
118
|
-
{ buffer: webpBuffer, mimeType: "image/webp" },
|
|
119
|
-
);
|
|
153
|
+
return pickSmallest(...candidates);
|
|
120
154
|
}
|
|
121
155
|
|
|
122
|
-
// Lossy
|
|
123
|
-
//
|
|
124
|
-
//
|
|
156
|
+
// Lossy encoder for quality/dimension fallback ladders. PNG is excluded since
|
|
157
|
+
// it's lossless and doesn't respond to quality parameters. WebP is included
|
|
158
|
+
// unless OMP_NO_WEBP is set (llama.cpp STB incompatibility).
|
|
125
159
|
async function encodeLossy(
|
|
126
160
|
width: number,
|
|
127
161
|
height: number,
|
|
128
162
|
quality: number,
|
|
129
163
|
): Promise<{ buffer: Uint8Array; mimeType: string }> {
|
|
130
|
-
const
|
|
131
|
-
new Bun.Image(inputBuffer)
|
|
132
|
-
|
|
164
|
+
const candidates = await Promise.all([
|
|
165
|
+
new Bun.Image(inputBuffer)
|
|
166
|
+
.resize(width, height)
|
|
167
|
+
.jpeg({ quality })
|
|
168
|
+
.bytes()
|
|
169
|
+
.then(b => ({ buffer: b, mimeType: "image/jpeg" })),
|
|
170
|
+
...(opts.excludeWebP
|
|
171
|
+
? []
|
|
172
|
+
: [
|
|
173
|
+
new Bun.Image(inputBuffer)
|
|
174
|
+
.resize(width, height)
|
|
175
|
+
.webp({ quality })
|
|
176
|
+
.bytes()
|
|
177
|
+
.then(b => ({ buffer: b, mimeType: "image/webp" })),
|
|
178
|
+
]),
|
|
133
179
|
]);
|
|
134
|
-
return pickSmallest(
|
|
135
|
-
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
|
136
|
-
{ buffer: webpBuffer, mimeType: "image/webp" },
|
|
137
|
-
);
|
|
180
|
+
return pickSmallest(...candidates);
|
|
138
181
|
}
|
|
139
|
-
|
|
140
182
|
// Quality ladder — more aggressive steps for tighter budgets
|
|
141
183
|
const qualitySteps = [70, 60, 50, 40];
|
|
142
184
|
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
|
@@ -145,7 +187,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
145
187
|
let finalWidth = targetWidth;
|
|
146
188
|
let finalHeight = targetHeight;
|
|
147
189
|
|
|
148
|
-
// First attempt: resize to target, try PNG/JPEG
|
|
190
|
+
// First attempt: resize to target, try PNG/JPEG (+ WebP), pick smallest
|
|
149
191
|
best = await encodeSmallest(targetWidth, targetHeight, opts.jpegQuality);
|
|
150
192
|
|
|
151
193
|
if (best.buffer.length <= opts.maxBytes) {
|
|
@@ -163,7 +205,7 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
163
205
|
};
|
|
164
206
|
}
|
|
165
207
|
|
|
166
|
-
// Still too large — lossy
|
|
208
|
+
// Still too large — lossy JPEG (+ WebP) ladder with decreasing quality
|
|
167
209
|
for (const quality of qualitySteps) {
|
|
168
210
|
best = await encodeLossy(targetWidth, targetHeight, quality);
|
|
169
211
|
|
|
@@ -226,7 +268,13 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
226
268
|
},
|
|
227
269
|
};
|
|
228
270
|
} catch {
|
|
229
|
-
//
|
|
271
|
+
// Bun.Image rejected the input — we cannot decode/re-encode it.
|
|
272
|
+
// When the caller demanded WebP exclusion AND the original is WebP,
|
|
273
|
+
// returning the original buffer would silently violate that contract,
|
|
274
|
+
// so surface an explicit error instead.
|
|
275
|
+
if (excludeWebP && (img.mimeType === "image/webp" || !img.mimeType)) {
|
|
276
|
+
throw new Error("resizeImage: failed to decode image and cannot honor excludeWebP for a WebP source");
|
|
277
|
+
}
|
|
230
278
|
return {
|
|
231
279
|
buffer: inputBuffer,
|
|
232
280
|
mimeType: img.mimeType,
|
|
@@ -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
|
-
}
|