@gajae-code/coding-agent 0.10.1 → 0.10.2
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/modes/components/gajae-pet-widget.d.ts +1 -0
- package/dist/types/modes/components/pet-capability.d.ts +37 -0
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/settings-selector.d.ts +9 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -1
- package/dist/types/modes/types.d.ts +11 -1
- package/package.json +7 -7
- package/src/config/model-profiles.ts +19 -19
- package/src/extensibility/slash-commands.ts +3 -3
- package/src/gjc-runtime/tmux-owner-isolation.ts +4 -1
- package/src/internal-urls/docs-index.generated.ts +3 -3
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/gajae-pet-widget.ts +169 -20
- package/src/modes/components/pet-capability.ts +115 -0
- package/src/modes/components/pet-selector.ts +4 -6
- package/src/modes/components/settings-selector.ts +28 -2
- package/src/modes/controllers/extension-ui-controller.ts +9 -8
- package/src/modes/controllers/input-controller.ts +43 -13
- package/src/modes/controllers/selector-controller.ts +7 -18
- package/src/modes/interactive-mode.ts +49 -4
- package/src/modes/types.ts +11 -1
- package/src/session/agent-session.ts +52 -7
- package/src/session/session-manager.ts +9 -2
- package/src/slash-commands/builtin-registry.ts +31 -29
|
@@ -248,6 +248,7 @@ export class CustomEditor extends Editor {
|
|
|
248
248
|
const paste = this.#pasteHandler.process(data);
|
|
249
249
|
if (paste.handled) {
|
|
250
250
|
if (paste.pasteContent !== undefined) {
|
|
251
|
+
this.onViewportFollowLive?.();
|
|
251
252
|
this.#handleBracketedPaste(paste.pasteContent, paste.remaining);
|
|
252
253
|
}
|
|
253
254
|
return;
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
type GajaePixelFrameName,
|
|
7
7
|
type GajaePixelFrames,
|
|
8
8
|
getCellDimensions,
|
|
9
|
-
ImageProtocol,
|
|
10
9
|
PARA_PARA_STEPS,
|
|
11
10
|
PET_SKINS,
|
|
12
11
|
type PetMode,
|
|
@@ -14,10 +13,10 @@ import {
|
|
|
14
13
|
petBurstDurationMs,
|
|
15
14
|
petBurstFrame,
|
|
16
15
|
registerAnimationCallback,
|
|
17
|
-
TERMINAL,
|
|
18
16
|
type TUI,
|
|
19
17
|
} from "@gajae-code/tui";
|
|
20
18
|
import type { CustomEditor } from "./custom-editor";
|
|
19
|
+
import { getPetPixelProtocol } from "./pet-capability";
|
|
21
20
|
|
|
22
21
|
/** Re-exported from the tui skin registry so widget-relative imports stay valid. */
|
|
23
22
|
export type { PetMode, PetSkinId };
|
|
@@ -38,6 +37,35 @@ const KITTY_DROP_FRACTION = 0.45;
|
|
|
38
37
|
const petKittyDropPx = (cellHeightPx: number): number =>
|
|
39
38
|
Math.min(Math.max(0, cellHeightPx - 1), Math.floor(cellHeightPx * KITTY_DROP_FRACTION));
|
|
40
39
|
const PET_RAISE_ROWS = 1;
|
|
40
|
+
const allocatedPetKittyImageIds = new Set<number>();
|
|
41
|
+
|
|
42
|
+
function allocatePetKittyImageId(): number {
|
|
43
|
+
let id = 0;
|
|
44
|
+
while (id === 0 || allocatedPetKittyImageIds.has(id)) {
|
|
45
|
+
id = crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
|
|
46
|
+
}
|
|
47
|
+
allocatedPetKittyImageIds.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface SixelFootprint {
|
|
52
|
+
x: number;
|
|
53
|
+
y: number;
|
|
54
|
+
columns: number;
|
|
55
|
+
rows: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sameFootprint(left: SixelFootprint, right: SixelFootprint): boolean {
|
|
59
|
+
return left.x === right.x && left.y === right.y && left.columns === right.columns && left.rows === right.rows;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which widget currently owns each TUI's single shared post-render emitter
|
|
64
|
+
* slot. A stale or repeated dispose (or off-switch) of a predecessor widget
|
|
65
|
+
* must never clear a successor's overlay authority.
|
|
66
|
+
*/
|
|
67
|
+
const petOverlayEmitterOwners = new WeakMap<TUI, GajaePetWidget>();
|
|
68
|
+
|
|
41
69
|
/** Working animation: the shared para-para beats looped end to end. */
|
|
42
70
|
const WORK_LOOP_TOTAL = PARA_PARA_STEPS.reduce((sum, [, ms]) => sum + ms, 0);
|
|
43
71
|
/** Random gap between automatic claw flexes (fires while idle AND working). */
|
|
@@ -127,6 +155,20 @@ export class GajaePetWidget {
|
|
|
127
155
|
/** Cell metrics the current frames were built for; a change triggers a rebuild. */
|
|
128
156
|
#builtCellW = 0;
|
|
129
157
|
#builtCellH = 0;
|
|
158
|
+
#kittyImageId: number | undefined;
|
|
159
|
+
/** True while a kitty placement may exist on screen; cleared only after the delete escape is delivered. */
|
|
160
|
+
#kittyCleanupPending = false;
|
|
161
|
+
/** Last emitted Sixel raster position; retained until an erase is actually delivered. */
|
|
162
|
+
#lastSixelFootprint: SixelFootprint | undefined;
|
|
163
|
+
/** Terminal state: a disposed widget never touches the TUI or shared slots again. */
|
|
164
|
+
#disposed = false;
|
|
165
|
+
/**
|
|
166
|
+
* True while the previous overlay frame carried the cleanup payload. The
|
|
167
|
+
* TUI writes the frame after the emitter returns, so delivery is
|
|
168
|
+
* acknowledged only on the next emitter pass — and only while the terminal
|
|
169
|
+
* stayed available, since a failed render write drops availability.
|
|
170
|
+
*/
|
|
171
|
+
#frameCleanupAwaitingAck = false;
|
|
130
172
|
|
|
131
173
|
constructor(options: {
|
|
132
174
|
ui: TUI;
|
|
@@ -154,9 +196,7 @@ export class GajaePetWidget {
|
|
|
154
196
|
|
|
155
197
|
/** Protocol available for the real-pixel pet, if any. */
|
|
156
198
|
static pixelProtocol(): "sixel" | "kitty" | null {
|
|
157
|
-
|
|
158
|
-
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
|
|
159
|
-
return null;
|
|
199
|
+
return getPetPixelProtocol();
|
|
160
200
|
}
|
|
161
201
|
|
|
162
202
|
get mode(): PetMode {
|
|
@@ -182,14 +222,19 @@ export class GajaePetWidget {
|
|
|
182
222
|
}
|
|
183
223
|
}
|
|
184
224
|
|
|
225
|
+
commitPreviewMode(mode: PetMode): void {
|
|
226
|
+
this.#applyMode(mode, false);
|
|
227
|
+
}
|
|
228
|
+
|
|
185
229
|
#applyMode(mode: PetMode, mountComposer: boolean): void {
|
|
186
|
-
if (mode === this.#mode) return;
|
|
230
|
+
if (this.#disposed || mode === this.#mode) return;
|
|
187
231
|
|
|
188
232
|
if (mode === "off") {
|
|
233
|
+
this.#writeImageCleanup();
|
|
189
234
|
this.#mode = "off";
|
|
190
235
|
this.#animation?.unregister();
|
|
191
236
|
this.#animation = undefined;
|
|
192
|
-
this.#
|
|
237
|
+
this.#releaseOverlayEmitter();
|
|
193
238
|
this.#floorContainer.clear();
|
|
194
239
|
this.#pixel = undefined;
|
|
195
240
|
this.#framedEditor.setReserve(0);
|
|
@@ -200,6 +245,7 @@ export class GajaePetWidget {
|
|
|
200
245
|
|
|
201
246
|
const protocol = this.#forcedProtocol ?? GajaePetWidget.pixelProtocol();
|
|
202
247
|
if (!protocol) return;
|
|
248
|
+
if (this.#mode !== "off") this.#writeImageCleanup();
|
|
203
249
|
this.#mode = mode;
|
|
204
250
|
this.#frame = "base";
|
|
205
251
|
this.#flexUntil = 0;
|
|
@@ -210,6 +256,7 @@ export class GajaePetWidget {
|
|
|
210
256
|
// the composer stays pinned to the terminal bottom.
|
|
211
257
|
this.#floorContainer.clear();
|
|
212
258
|
this.#ui.setPostRenderEmitter(() => this.#overlayPayload());
|
|
259
|
+
petOverlayEmitterOwners.set(this.#ui, this);
|
|
213
260
|
this.#animation ??= registerAnimationCallback(now => this.#tick(now), 80);
|
|
214
261
|
this.#ui.requestRender(true);
|
|
215
262
|
}
|
|
@@ -220,6 +267,10 @@ export class GajaePetWidget {
|
|
|
220
267
|
this.#builtCellW = cell.widthPx;
|
|
221
268
|
this.#builtCellH = cell.heightPx;
|
|
222
269
|
const skin: PetSkinId = this.#mode === "off" ? "red" : this.#mode;
|
|
270
|
+
if (protocol === "kitty") {
|
|
271
|
+
this.#kittyImageId ??= allocatePetKittyImageId();
|
|
272
|
+
this.#kittyCleanupPending = true;
|
|
273
|
+
}
|
|
223
274
|
this.#pixel = buildGajaePixelFrames({
|
|
224
275
|
protocol,
|
|
225
276
|
skin,
|
|
@@ -228,17 +279,49 @@ export class GajaePetWidget {
|
|
|
228
279
|
targetRows: 2,
|
|
229
280
|
sixelTopPaddingPx: protocol === "sixel" ? PET_SIXEL_DROP_PX : 0,
|
|
230
281
|
kittyCellYOffsetPx: protocol === "kitty" ? petKittyDropPx(cell.heightPx) : 0,
|
|
282
|
+
kittyImageId: protocol === "kitty" ? this.#kittyImageId : undefined,
|
|
231
283
|
});
|
|
232
284
|
this.#framedEditor.setReserve(this.#pixel.columns + PET_SIDE_MARGIN);
|
|
233
285
|
}
|
|
234
286
|
|
|
235
287
|
dispose(): void {
|
|
236
|
-
this.#
|
|
237
|
-
this.#
|
|
238
|
-
this.#
|
|
239
|
-
this.#
|
|
240
|
-
|
|
241
|
-
|
|
288
|
+
if (this.#disposed) return;
|
|
289
|
+
this.#disposed = true;
|
|
290
|
+
const kittyImageId = this.#kittyImageId;
|
|
291
|
+
const cleanupPayload = this.#imageCleanupPayload();
|
|
292
|
+
try {
|
|
293
|
+
if (cleanupPayload) {
|
|
294
|
+
this.#ui.queueTerminalCleanup(
|
|
295
|
+
`\x1b[?2026h\x1b7${cleanupPayload}\x1b8\x1b[?2026l`,
|
|
296
|
+
kittyImageId === undefined ? undefined : () => allocatedPetKittyImageIds.delete(kittyImageId),
|
|
297
|
+
);
|
|
298
|
+
} else if (kittyImageId !== undefined) {
|
|
299
|
+
allocatedPetKittyImageIds.delete(kittyImageId);
|
|
300
|
+
}
|
|
301
|
+
this.#consumeCleanupAuthority();
|
|
302
|
+
this.#kittyImageId = undefined;
|
|
303
|
+
} finally {
|
|
304
|
+
this.#animation?.unregister();
|
|
305
|
+
this.#animation = undefined;
|
|
306
|
+
this.#releaseOverlayEmitter();
|
|
307
|
+
this.#mode = "off";
|
|
308
|
+
this.#pixel = undefined;
|
|
309
|
+
this.#floorContainer.clear();
|
|
310
|
+
this.#framedEditor.setReserve(0);
|
|
311
|
+
// Restore the plain composer only while our framed wrapper is still
|
|
312
|
+
// mounted; a successor widget may already own the editor container.
|
|
313
|
+
if (this.#editorContainer.children.includes(this.#framedEditor)) {
|
|
314
|
+
this.#mountEditor(false);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Clear the shared post-render slot only while this widget still owns it. */
|
|
320
|
+
#releaseOverlayEmitter(): void {
|
|
321
|
+
if (petOverlayEmitterOwners.get(this.#ui) === this) {
|
|
322
|
+
this.#ui.setPostRenderEmitter(undefined);
|
|
323
|
+
petOverlayEmitterOwners.delete(this.#ui);
|
|
324
|
+
}
|
|
242
325
|
}
|
|
243
326
|
|
|
244
327
|
#mountEditor(framed: boolean): void {
|
|
@@ -335,25 +418,91 @@ export class GajaePetWidget {
|
|
|
335
418
|
return { x, y };
|
|
336
419
|
}
|
|
337
420
|
|
|
338
|
-
#
|
|
339
|
-
const pixel = this.#pixel;
|
|
340
|
-
if (pixel?.protocol !== "sixel") return "";
|
|
421
|
+
#clearSixelFootprint(footprint: SixelFootprint): string {
|
|
341
422
|
let out = "\x1b[0m";
|
|
342
|
-
for (let row = 0; row <
|
|
343
|
-
out += `\x1b[${y + row + 1};${x + 1}H\x1b[${
|
|
423
|
+
for (let row = 0; row < footprint.rows; row++) {
|
|
424
|
+
out += `\x1b[${footprint.y + row + 1};${footprint.x + 1}H\x1b[${footprint.columns}X`;
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Pending on-screen image cleanup. Pure: authority is consumed separately, on delivery. */
|
|
430
|
+
#imageCleanupPayload(): string {
|
|
431
|
+
let out = "";
|
|
432
|
+
if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) {
|
|
433
|
+
out += `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
|
|
434
|
+
}
|
|
435
|
+
if (this.#lastSixelFootprint) {
|
|
436
|
+
out += this.#clearSixelFootprint(this.#lastSixelFootprint);
|
|
344
437
|
}
|
|
345
438
|
return out;
|
|
346
439
|
}
|
|
347
440
|
|
|
441
|
+
#consumeCleanupAuthority(): void {
|
|
442
|
+
this.#kittyCleanupPending = false;
|
|
443
|
+
this.#lastSixelFootprint = undefined;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Best-effort direct erase of the on-screen pet image. Cleanup authority is
|
|
448
|
+
* consumed only after the write is actually delivered: an unavailable
|
|
449
|
+
* terminal or a throwing write keeps the erase pending so a later mode
|
|
450
|
+
* switch or dispose can retry it.
|
|
451
|
+
*/
|
|
452
|
+
#writeImageCleanup(): void {
|
|
453
|
+
if (!this.#ui.terminalAvailable) return;
|
|
454
|
+
const payload = this.#imageCleanupPayload();
|
|
455
|
+
if (!payload) return;
|
|
456
|
+
try {
|
|
457
|
+
this.#ui.terminal.write(`\x1b[?2026h\x1b7${payload}\x1b8\x1b[?2026l`);
|
|
458
|
+
} catch {
|
|
459
|
+
// Keep the footprint/placement authority; the terminal write layer
|
|
460
|
+
// reports availability separately and callers retry on the next
|
|
461
|
+
// lifecycle transition.
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
this.#consumeCleanupAuthority();
|
|
465
|
+
}
|
|
466
|
+
|
|
348
467
|
/** Draw escape payload at the pet's absolute position. */
|
|
349
468
|
#overlayPayload(clearPet = false): string | null {
|
|
350
469
|
const pixel = this.#pixel;
|
|
351
470
|
if (!pixel) return null;
|
|
352
471
|
const pos = this.#petPosition();
|
|
353
|
-
if (!pos)
|
|
472
|
+
if (!pos) {
|
|
473
|
+
// Deferred delivery acknowledgement: the TUI writes the frame after
|
|
474
|
+
// this emitter returns, and that write can fail. Consume the cleanup
|
|
475
|
+
// authority only once a later pass observes the terminal survived
|
|
476
|
+
// the frame that carried the payload; otherwise retain it so a later
|
|
477
|
+
// lifecycle cleanup retries the erase/delete.
|
|
478
|
+
if (this.#frameCleanupAwaitingAck && this.#ui.terminalAvailable) {
|
|
479
|
+
this.#consumeCleanupAuthority();
|
|
480
|
+
}
|
|
481
|
+
this.#frameCleanupAwaitingAck = false;
|
|
482
|
+
if (!this.#ui.terminalAvailable) return null;
|
|
483
|
+
const cleanup = this.#imageCleanupPayload();
|
|
484
|
+
if (!cleanup) return null;
|
|
485
|
+
this.#frameCleanupAwaitingAck = true;
|
|
486
|
+
return cleanup;
|
|
487
|
+
}
|
|
488
|
+
// A full frame supersedes any cleanup-only frame still awaiting ack.
|
|
489
|
+
this.#frameCleanupAwaitingAck = false;
|
|
354
490
|
const { x, y } = pos;
|
|
491
|
+
let out = "";
|
|
492
|
+
|
|
493
|
+
if (pixel.protocol === "sixel") {
|
|
494
|
+
const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows };
|
|
495
|
+
if (this.#lastSixelFootprint && !sameFootprint(this.#lastSixelFootprint, footprint)) {
|
|
496
|
+
out += this.#clearSixelFootprint(this.#lastSixelFootprint);
|
|
497
|
+
}
|
|
498
|
+
if (clearPet) out += this.#clearSixelFootprint(footprint);
|
|
499
|
+
this.#lastSixelFootprint = footprint;
|
|
500
|
+
} else {
|
|
501
|
+
// A kitty frame emitted below (re)places the image, so cleanup is
|
|
502
|
+
// pending again even if a narrow-terminal pass consumed it earlier.
|
|
503
|
+
this.#kittyCleanupPending = true;
|
|
504
|
+
}
|
|
355
505
|
|
|
356
|
-
let out = clearPet ? this.#clearPetPayload(x, y) : "";
|
|
357
506
|
out += `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`;
|
|
358
507
|
return out;
|
|
359
508
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ImageProtocol,
|
|
3
|
+
isUnderTerminalMultiplexer,
|
|
4
|
+
onImageProtocolChanged,
|
|
5
|
+
type SelectItem,
|
|
6
|
+
shouldProbeSixelCapability,
|
|
7
|
+
TERMINAL,
|
|
8
|
+
} from "@gajae-code/tui";
|
|
9
|
+
|
|
10
|
+
export type PetPixelProtocol = "sixel" | "kitty";
|
|
11
|
+
|
|
12
|
+
export const PET_UNAVAILABLE_DESCRIPTION = "Unavailable: requires compatible Kitty or Sixel overlay rendering";
|
|
13
|
+
export const PET_SAVED_UNAVAILABLE_DESCRIPTION =
|
|
14
|
+
"Saved, unavailable — requires compatible Kitty or Sixel overlay rendering";
|
|
15
|
+
export const PET_UNAVAILABLE_WARNING =
|
|
16
|
+
"⚠ Pets aren’t available in this terminal. Its image support isn’t compatible with Gajae Pet’s overlay rendering yet. Try Kitty, Ghostty, WezTerm, or a terminal with compatible Sixel support.";
|
|
17
|
+
const PET_MULTIPLEXER_UNAVAILABLE_WARNING =
|
|
18
|
+
"⚠ Gajae Pet graphics are unavailable inside tmux, screen, or zellij because image escapes are not forwarded end to end. Run gjc outside the multiplexer, or set PI_FORCE_IMAGE_PROTOCOL=sixel only when the full terminal chain supports Sixel.";
|
|
19
|
+
|
|
20
|
+
export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): string {
|
|
21
|
+
return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getPetPixelProtocol(): PetPixelProtocol | null {
|
|
25
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
|
|
26
|
+
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isPetAvailable(): boolean {
|
|
31
|
+
return getPetPixelProtocol() !== null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createPetSelectItems(
|
|
35
|
+
options: ReadonlyArray<SelectItem>,
|
|
36
|
+
currentValue: string,
|
|
37
|
+
available: boolean,
|
|
38
|
+
): SelectItem[] {
|
|
39
|
+
return options.map(option => {
|
|
40
|
+
const disabled = !available && option.value !== "off";
|
|
41
|
+
const current = option.value === currentValue;
|
|
42
|
+
const savedUnavailable = disabled && current;
|
|
43
|
+
let description = `${option.description ?? ""}${current ? " (current)" : ""}`;
|
|
44
|
+
if (disabled) {
|
|
45
|
+
description = savedUnavailable ? PET_SAVED_UNAVAILABLE_DESCRIPTION : PET_UNAVAILABLE_DESCRIPTION;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...option,
|
|
49
|
+
label: savedUnavailable ? `${option.label} (saved)` : option.label,
|
|
50
|
+
description,
|
|
51
|
+
disabled,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Grace period before declaring the terminal pet-incapable at startup. The
|
|
58
|
+
* asynchronous Sixel capability probe starts inside `TUI.start()` and answers
|
|
59
|
+
* within its own 250 ms deadline; this margin covers probe scheduling so a
|
|
60
|
+
* supported terminal is never told it is incompatible while the probe is
|
|
61
|
+
* still in flight.
|
|
62
|
+
*/
|
|
63
|
+
export const PET_CAPABILITY_SETTLE_MS = 1_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether the asynchronous startup Sixel capability probe may still enable
|
|
67
|
+
* graphics for this session, meaning current unavailability is not final.
|
|
68
|
+
*/
|
|
69
|
+
export function isPetCapabilityProbePending(
|
|
70
|
+
env: NodeJS.ProcessEnv = Bun.env,
|
|
71
|
+
platform: NodeJS.Platform = process.platform,
|
|
72
|
+
): boolean {
|
|
73
|
+
if (TERMINAL.imageProtocol !== null) return false;
|
|
74
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
|
75
|
+
return shouldProbeSixelCapability(env, platform);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Deliver the pet-unavailable startup warning only once the capability
|
|
80
|
+
* question is settled. With no probe pending, unavailability is final and
|
|
81
|
+
* `onUnavailable` fires immediately. While a probe may still enable
|
|
82
|
+
* graphics, the warning is deferred: a protocol-change event cancels it (the
|
|
83
|
+
* saved pet re-applies through the existing subscription), and only the
|
|
84
|
+
* settle deadline passing with the terminal still unavailable emits it.
|
|
85
|
+
* Returns a disposer that cancels the pending decision.
|
|
86
|
+
*/
|
|
87
|
+
export function warnWhenPetCapabilitySettled(options: {
|
|
88
|
+
probePending: boolean;
|
|
89
|
+
isAvailable?: () => boolean;
|
|
90
|
+
onUnavailable: () => void;
|
|
91
|
+
settleMs?: number;
|
|
92
|
+
}): () => void {
|
|
93
|
+
if (!options.probePending) {
|
|
94
|
+
options.onUnavailable();
|
|
95
|
+
return () => {};
|
|
96
|
+
}
|
|
97
|
+
const isAvailable = options.isAvailable ?? isPetAvailable;
|
|
98
|
+
let settled = false;
|
|
99
|
+
const finish = () => {
|
|
100
|
+
if (settled) return;
|
|
101
|
+
settled = true;
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
unsubscribe();
|
|
104
|
+
};
|
|
105
|
+
const unsubscribe = onImageProtocolChanged(protocol => {
|
|
106
|
+
if (!protocol) return;
|
|
107
|
+
finish();
|
|
108
|
+
});
|
|
109
|
+
const timer = setTimeout(() => {
|
|
110
|
+
finish();
|
|
111
|
+
if (!isAvailable()) options.onUnavailable();
|
|
112
|
+
}, options.settleMs ?? PET_CAPABILITY_SETTLE_MS);
|
|
113
|
+
timer.unref?.();
|
|
114
|
+
return finish;
|
|
115
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Container, PET_SKIN_IDS, PET_SKINS,
|
|
1
|
+
import { Container, PET_SKIN_IDS, PET_SKINS, SelectList } from "@gajae-code/tui";
|
|
2
2
|
import { getSelectListTheme } from "../theme/theme";
|
|
3
3
|
import { DynamicBorder } from "./dynamic-border";
|
|
4
4
|
import type { PetMode } from "./gajae-pet-widget";
|
|
5
|
+
import { createPetSelectItems } from "./pet-capability";
|
|
5
6
|
|
|
6
7
|
const PET_OPTIONS: { value: PetMode; label: string; description: string }[] = [
|
|
7
8
|
{ value: "off", label: "Off", description: "No pet" },
|
|
@@ -24,14 +25,11 @@ export class PetSelectorComponent extends Container {
|
|
|
24
25
|
onSelect: (mode: PetMode) => void,
|
|
25
26
|
onCancel: () => void,
|
|
26
27
|
onPreview: (mode: PetMode) => void,
|
|
28
|
+
available: boolean,
|
|
27
29
|
) {
|
|
28
30
|
super();
|
|
29
31
|
|
|
30
|
-
const items
|
|
31
|
-
value: option.value,
|
|
32
|
-
label: option.label,
|
|
33
|
-
description: option.value === current ? `${option.description} (current)` : option.description,
|
|
34
|
-
}));
|
|
32
|
+
const items = createPetSelectItems(PET_OPTIONS, current, available);
|
|
35
33
|
|
|
36
34
|
this.addChild(new DynamicBorder());
|
|
37
35
|
|
|
@@ -25,6 +25,7 @@ import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme }
|
|
|
25
25
|
import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
|
26
26
|
import { getTabBarTheme } from "../shared";
|
|
27
27
|
import { DynamicBorder } from "./dynamic-border";
|
|
28
|
+
import { createPetSelectItems, getPetUnavailableWarning, isPetAvailable } from "./pet-capability";
|
|
28
29
|
import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
|
|
29
30
|
import { getSettingsForTab, type SettingDef } from "./settings-defs";
|
|
30
31
|
import type { StatusLineSegmentOptions } from "./status-line";
|
|
@@ -661,6 +662,8 @@ export interface SettingsRuntimeContext {
|
|
|
661
662
|
availableModelProfiles: string[];
|
|
662
663
|
/** Working directory for plugins tab */
|
|
663
664
|
cwd: string;
|
|
665
|
+
/** Whether this terminal can render the pet overlay. */
|
|
666
|
+
petAvailable?: boolean;
|
|
664
667
|
}
|
|
665
668
|
|
|
666
669
|
/** Status line settings subset for preview */
|
|
@@ -684,6 +687,13 @@ export interface SettingsCallbacks {
|
|
|
684
687
|
onThemePreviewCancel?: (theme: string) => void | Promise<void>;
|
|
685
688
|
/** Called to live-preview the gajae pet skin while browsing the pet setting. */
|
|
686
689
|
onPetPreview?: (mode: string) => void;
|
|
690
|
+
/**
|
|
691
|
+
* Commit a pet mode through the shared result-returning policy. The policy
|
|
692
|
+
* rechecks capability immediately before mutation and owns persistence, so
|
|
693
|
+
* the settings surface never persists `pet.mode` ahead of acceptance.
|
|
694
|
+
* Returns whether the commit was accepted.
|
|
695
|
+
*/
|
|
696
|
+
onPetCommit?: (mode: string) => boolean;
|
|
687
697
|
/** Called for status line preview while configuring */
|
|
688
698
|
onStatusLinePreview?: (settings: StatusLinePreviewSettings) => void;
|
|
689
699
|
/** Get current rendered status line for inline preview */
|
|
@@ -841,7 +851,7 @@ export class SettingsSelectorComponent extends Container {
|
|
|
841
851
|
currentValue: string,
|
|
842
852
|
done: (value?: string) => void,
|
|
843
853
|
): Container {
|
|
844
|
-
let options = def.options;
|
|
854
|
+
let options: ReadonlyArray<SelectItem> = def.options;
|
|
845
855
|
|
|
846
856
|
// Special case: inject runtime options for thinking level
|
|
847
857
|
if (def.path === "defaultThinkingLevel") {
|
|
@@ -857,6 +867,14 @@ export class SettingsSelectorComponent extends Container {
|
|
|
857
867
|
if (def.path === "statusLine.preset") {
|
|
858
868
|
options = options.filter(option => option.value !== "custom");
|
|
859
869
|
}
|
|
870
|
+
let description = def.description;
|
|
871
|
+
if (def.path === "pet.mode") {
|
|
872
|
+
const petAvailable = this.context.petAvailable ?? isPetAvailable();
|
|
873
|
+
options = createPetSelectItems(options, currentValue, petAvailable);
|
|
874
|
+
// Unsupported terminals must see the same actionable guidance the
|
|
875
|
+
// startup notice and /pet show, not only dimmed option descriptions.
|
|
876
|
+
if (!petAvailable) description = getPetUnavailableWarning();
|
|
877
|
+
}
|
|
860
878
|
// Preview handlers
|
|
861
879
|
let onPreview: ((value: string) => void | Promise<void>) | undefined;
|
|
862
880
|
let onPreviewCancel: (() => void) | undefined;
|
|
@@ -943,10 +961,18 @@ export class SettingsSelectorComponent extends Container {
|
|
|
943
961
|
|
|
944
962
|
return new SelectSubmenu(
|
|
945
963
|
def.label,
|
|
946
|
-
|
|
964
|
+
description,
|
|
947
965
|
options,
|
|
948
966
|
currentValue,
|
|
949
967
|
value => {
|
|
968
|
+
if (def.path === "pet.mode") {
|
|
969
|
+
// The shared pet commit policy rechecks capability immediately
|
|
970
|
+
// before mutation and persists only on acceptance; the settings
|
|
971
|
+
// surface must not persist ahead of that result.
|
|
972
|
+
const accepted = this.callbacks.onPetCommit?.(value) ?? false;
|
|
973
|
+
done(accepted ? value : undefined);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
950
976
|
this.#setSettingValue(def.path, value);
|
|
951
977
|
this.callbacks.onChange(def.path, value);
|
|
952
978
|
done(value);
|
|
@@ -50,6 +50,11 @@ export class ExtensionUiController {
|
|
|
50
50
|
overlay?.hide();
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Re-mount the pet-aware composer after a transient hook UI closes. */
|
|
54
|
+
#restoreComposerEditor(): void {
|
|
55
|
+
this.ctx.restoreComposer();
|
|
56
|
+
}
|
|
57
|
+
|
|
53
58
|
/**
|
|
54
59
|
* Initialize the hook system with TUI-based UI context.
|
|
55
60
|
*/
|
|
@@ -723,8 +728,7 @@ export class ExtensionUiController {
|
|
|
723
728
|
*/
|
|
724
729
|
hideHookSelector(): void {
|
|
725
730
|
this.ctx.hookSelector?.dispose();
|
|
726
|
-
this
|
|
727
|
-
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
731
|
+
this.#restoreComposerEditor();
|
|
728
732
|
this.ctx.hookSelector = undefined;
|
|
729
733
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
730
734
|
this.ctx.ui.requestRender();
|
|
@@ -785,8 +789,7 @@ export class ExtensionUiController {
|
|
|
785
789
|
*/
|
|
786
790
|
hideHookInput(): void {
|
|
787
791
|
this.ctx.hookInput?.dispose();
|
|
788
|
-
this
|
|
789
|
-
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
792
|
+
this.#restoreComposerEditor();
|
|
790
793
|
this.ctx.hookInput = undefined;
|
|
791
794
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
792
795
|
this.ctx.ui.requestRender();
|
|
@@ -835,8 +838,7 @@ export class ExtensionUiController {
|
|
|
835
838
|
* Hide the hook editor.
|
|
836
839
|
*/
|
|
837
840
|
hideHookEditor(): void {
|
|
838
|
-
this
|
|
839
|
-
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
841
|
+
this.#restoreComposerEditor();
|
|
840
842
|
this.ctx.hookEditor = undefined;
|
|
841
843
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
842
844
|
this.ctx.ui.requestRender();
|
|
@@ -879,8 +881,7 @@ export class ExtensionUiController {
|
|
|
879
881
|
closed = true;
|
|
880
882
|
this.#clearActiveHookCustom();
|
|
881
883
|
if (!options?.overlay) {
|
|
882
|
-
this
|
|
883
|
-
this.ctx.editorContainer.addChild(this.ctx.editor);
|
|
884
|
+
this.#restoreComposerEditor();
|
|
884
885
|
this.ctx.editor.setText(savedText);
|
|
885
886
|
}
|
|
886
887
|
this.ctx.ui.setFocus(this.ctx.editor);
|
|
@@ -434,13 +434,14 @@ export class InputController {
|
|
|
434
434
|
}
|
|
435
435
|
|
|
436
436
|
const runner = this.ctx.session.extensionRunner;
|
|
437
|
+
const pendingImages = this.ctx.pendingImages;
|
|
437
438
|
let inputImages = this.#visiblePendingImagesForText(text);
|
|
438
439
|
|
|
439
440
|
if (runner?.hasHandlers("input")) {
|
|
440
441
|
const result = await runner.emitInput(text, inputImages, "interactive");
|
|
441
442
|
if (result?.handled) {
|
|
442
443
|
this.ctx.editor.setText("");
|
|
443
|
-
this
|
|
444
|
+
this.#clearPendingImagesIfOwnedBy(pendingImages);
|
|
444
445
|
return;
|
|
445
446
|
}
|
|
446
447
|
if (result?.text !== undefined) {
|
|
@@ -530,7 +531,7 @@ export class InputController {
|
|
|
530
531
|
this.ctx.editor.addToHistory(text);
|
|
531
532
|
this.ctx.editor.setText("");
|
|
532
533
|
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
533
|
-
this
|
|
534
|
+
this.#clearPendingImagesIfOwnedBy(pendingImages);
|
|
534
535
|
// Record the signature so the queued message's eventual delivery
|
|
535
536
|
// (a user-role `message_start` event) leaves any draft the user has
|
|
536
537
|
// typed since queuing intact. Same protection as #783, applied to
|
|
@@ -582,7 +583,7 @@ export class InputController {
|
|
|
582
583
|
if (this.ctx.onInputCallback) {
|
|
583
584
|
// Include any pending images from clipboard paste
|
|
584
585
|
const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
|
|
585
|
-
this
|
|
586
|
+
this.#clearPendingImagesIfOwnedBy(pendingImages);
|
|
586
587
|
|
|
587
588
|
// Render user message immediately, then let session events catch up
|
|
588
589
|
const submission = this.ctx.startPendingSubmission({ text, images });
|
|
@@ -1104,11 +1105,14 @@ export class InputController {
|
|
|
1104
1105
|
return false;
|
|
1105
1106
|
}
|
|
1106
1107
|
|
|
1107
|
-
this.ctx.pendingImages
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1108
|
+
this.ctx.pendingImages = [
|
|
1109
|
+
...this.ctx.pendingImages,
|
|
1110
|
+
{
|
|
1111
|
+
type: "image",
|
|
1112
|
+
data: image.data,
|
|
1113
|
+
mimeType: image.mimeType,
|
|
1114
|
+
},
|
|
1115
|
+
];
|
|
1112
1116
|
this.ctx.editor.insertText(`${formatPastedImageReference(this.#nextImagePlaceholder(), image.resolvedPath)} `);
|
|
1113
1117
|
this.ctx.showStatus(`Attached image: ${path.basename(image.resolvedPath)}`, { dim: true });
|
|
1114
1118
|
this.ctx.ui.requestRender();
|
|
@@ -1151,10 +1155,33 @@ export class InputController {
|
|
|
1151
1155
|
return images.length > 0 ? images : undefined;
|
|
1152
1156
|
}
|
|
1153
1157
|
|
|
1158
|
+
#clearPendingImagesIfOwnedBy(pendingImages: InteractiveModeContext["pendingImages"]): void {
|
|
1159
|
+
if (this.ctx.pendingImages === pendingImages) {
|
|
1160
|
+
this.ctx.pendingImages = [];
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1154
1164
|
#clearPendingImagesIfPlaceholdersRemoved(text: string): void {
|
|
1155
1165
|
if (this.ctx.pendingImages.length === 0 || IMAGE_PLACEHOLDER_PRESENT_PATTERN.test(text)) {
|
|
1156
1166
|
return;
|
|
1157
1167
|
}
|
|
1168
|
+
// Editor submit resets the composer and emits onChange("") before onSubmit(result).
|
|
1169
|
+
// Defer the empty-buffer clear so the submit handler can still resolve image
|
|
1170
|
+
// placeholders against pendingImages, while manual clears still drop stale images.
|
|
1171
|
+
if (text.length === 0) {
|
|
1172
|
+
const pendingImages = this.ctx.pendingImages;
|
|
1173
|
+
const pendingImageCount = pendingImages.length;
|
|
1174
|
+
queueMicrotask(() => {
|
|
1175
|
+
if (
|
|
1176
|
+
this.ctx.pendingImages === pendingImages &&
|
|
1177
|
+
pendingImages.length === pendingImageCount &&
|
|
1178
|
+
this.ctx.editor.getText().length === 0
|
|
1179
|
+
) {
|
|
1180
|
+
this.ctx.pendingImages = [];
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1158
1185
|
this.ctx.pendingImages = [];
|
|
1159
1186
|
}
|
|
1160
1187
|
|
|
@@ -1185,11 +1212,14 @@ export class InputController {
|
|
|
1185
1212
|
}
|
|
1186
1213
|
}
|
|
1187
1214
|
|
|
1188
|
-
this.ctx.pendingImages
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1215
|
+
this.ctx.pendingImages = [
|
|
1216
|
+
...this.ctx.pendingImages,
|
|
1217
|
+
{
|
|
1218
|
+
type: "image",
|
|
1219
|
+
data: imageData.data,
|
|
1220
|
+
mimeType: imageData.mimeType,
|
|
1221
|
+
},
|
|
1222
|
+
];
|
|
1193
1223
|
this.ctx.editor.insertText(`${this.#nextImagePlaceholder()} `);
|
|
1194
1224
|
this.ctx.ui.requestRender();
|
|
1195
1225
|
return true;
|