@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.7
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 +29 -1
- package/dist/cli.js +114 -71
- package/dist/types/cli/bench-cli.d.ts +78 -0
- package/dist/types/commands/bench.d.ts +29 -0
- package/dist/types/config/model-resolver.d.ts +3 -2
- package/dist/types/config/settings-schema.d.ts +72 -0
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -1
- package/dist/types/modes/components/settings-selector.d.ts +8 -1
- package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
- package/dist/types/modes/components/tool-execution.d.ts +13 -9
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
- package/dist/types/session/snapcompact-inline.d.ts +2 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/eval-render.d.ts +1 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +1 -0
- package/package.json +11 -11
- package/src/cli/bench-cli.ts +437 -0
- package/src/cli-commands.ts +1 -0
- package/src/commands/bench.ts +42 -0
- package/src/config/model-registry.ts +52 -5
- package/src/config/model-resolver.ts +36 -5
- package/src/config/settings-schema.ts +92 -0
- package/src/edit/renderer.ts +5 -0
- package/src/hindsight/client.ts +26 -1
- package/src/hindsight/state.ts +6 -2
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/mcp/transports/stdio.ts +81 -7
- package/src/modes/components/oauth-selector.ts +67 -7
- package/src/modes/components/settings-selector.ts +27 -0
- package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
- package/src/modes/components/snapcompact-shape-preview.ts +192 -0
- package/src/modes/components/tool-execution.ts +18 -10
- package/src/modes/controllers/input-controller.ts +8 -6
- package/src/modes/controllers/selector-controller.ts +4 -2
- package/src/modes/interactive-mode.ts +24 -0
- package/src/modes/setup-wizard/index.ts +1 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
- package/src/modes/setup-wizard/scenes/providers.ts +36 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
- package/src/modes/setup-wizard/scenes/theme.ts +28 -1
- package/src/modes/setup-wizard/scenes/types.ts +10 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
- package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
- package/src/modes/utils/context-usage.ts +1 -1
- package/src/prompts/bench.md +7 -0
- package/src/sdk.ts +1 -0
- package/src/session/agent-session.ts +5 -0
- package/src/session/snapcompact-inline.ts +11 -19
- package/src/tools/bash.ts +3 -0
- package/src/tools/eval-render.ts +4 -0
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +3 -0
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
fuzzyFilter,
|
|
7
7
|
matchesKey,
|
|
8
8
|
ScrollView,
|
|
9
|
+
type SgrMouseEvent,
|
|
9
10
|
Spacer,
|
|
10
11
|
TruncatedText,
|
|
11
12
|
} from "@oh-my-pi/pi-tui";
|
|
@@ -16,6 +17,12 @@ import { DynamicBorder } from "./dynamic-border";
|
|
|
16
17
|
|
|
17
18
|
const OAUTH_SELECTOR_MAX_VISIBLE = 10;
|
|
18
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Rendered lines before the provider rows: top border, spacer, title, spacer
|
|
22
|
+
* (must mirror the constructor's addChild order).
|
|
23
|
+
*/
|
|
24
|
+
const LIST_ROW_OFFSET = 4;
|
|
25
|
+
|
|
19
26
|
/** Compact, human-readable tag for each credential-origin leg. */
|
|
20
27
|
const ORIGIN_LABELS: Record<CredentialOriginKind, string> = {
|
|
21
28
|
runtime: "--api-key",
|
|
@@ -34,6 +41,10 @@ export class OAuthSelectorComponent extends Container {
|
|
|
34
41
|
#filteredProviders: OAuthProviderInfo[] = [];
|
|
35
42
|
#searchQuery = "";
|
|
36
43
|
#selectedIndex: number = 0;
|
|
44
|
+
#hoveredIndex: number | null = null;
|
|
45
|
+
/** First provider index of the visible ScrollView window (last #updateList). */
|
|
46
|
+
#scrollStart = 0;
|
|
47
|
+
#visibleCount = 0;
|
|
37
48
|
#mode: "login" | "logout";
|
|
38
49
|
#authStorage: AuthStorage;
|
|
39
50
|
#onSelectCallback: (providerId: string) => void;
|
|
@@ -252,6 +263,8 @@ export class OAuthSelectorComponent extends Container {
|
|
|
252
263
|
? 0
|
|
253
264
|
: Math.max(0, Math.min(this.#selectedIndex - Math.floor(maxVisible / 2), total - maxVisible));
|
|
254
265
|
const endIndex = Math.min(startIndex + maxVisible, total);
|
|
266
|
+
this.#scrollStart = startIndex;
|
|
267
|
+
this.#visibleCount = endIndex - startIndex;
|
|
255
268
|
|
|
256
269
|
const rows: string[] = [];
|
|
257
270
|
for (let i = startIndex; i < endIndex; i++) {
|
|
@@ -270,6 +283,9 @@ export class OAuthSelectorComponent extends Container {
|
|
|
270
283
|
const text = isAvailable ? ` ${provider.name}` : theme.fg("dim", ` ${provider.name}`);
|
|
271
284
|
line = text + statusIndicator;
|
|
272
285
|
}
|
|
286
|
+
if (!isSelected && i === this.#hoveredIndex) {
|
|
287
|
+
line = theme.bg("selectedBg", line);
|
|
288
|
+
}
|
|
273
289
|
rows.push(line);
|
|
274
290
|
}
|
|
275
291
|
|
|
@@ -354,15 +370,59 @@ export class OAuthSelectorComponent extends Container {
|
|
|
354
370
|
}
|
|
355
371
|
// Enter
|
|
356
372
|
else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
373
|
+
this.#confirmSelection();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Confirm the selected provider (Enter or mouse click). */
|
|
378
|
+
#confirmSelection(): void {
|
|
379
|
+
const selectedProvider = this.#filteredProviders[this.#selectedIndex];
|
|
380
|
+
if (selectedProvider?.available) {
|
|
381
|
+
this.#statusMessage = undefined;
|
|
382
|
+
this.stopValidation();
|
|
383
|
+
this.#onSelectCallback(selectedProvider.id);
|
|
384
|
+
} else if (selectedProvider) {
|
|
385
|
+
this.#statusMessage = "Provider unavailable in this environment.";
|
|
386
|
+
this.#updateList();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Move the selection one step for a wheel notch (clamped, no wrap). */
|
|
391
|
+
handleWheel(delta: -1 | 1): void {
|
|
392
|
+
if (this.#filteredProviders.length === 0) return;
|
|
393
|
+
const next = Math.max(0, Math.min(this.#selectedIndex + delta, this.#filteredProviders.length - 1));
|
|
394
|
+
if (next === this.#selectedIndex) return;
|
|
395
|
+
this.#selectedIndex = next;
|
|
396
|
+
this.#statusMessage = undefined;
|
|
397
|
+
this.#updateList();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Route an SGR mouse report at component-local coordinates. Provider rows
|
|
402
|
+
* start LIST_ROW_OFFSET lines into the render; the ScrollView window shows
|
|
403
|
+
* #visibleCount rows from #scrollStart. Wheel moves the selection, motion
|
|
404
|
+
* drives the hover band, and a left click selects and confirms like Enter.
|
|
405
|
+
*/
|
|
406
|
+
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
|
|
407
|
+
if (event.wheel !== null) {
|
|
408
|
+
this.handleWheel(event.wheel);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const localRow = line - LIST_ROW_OFFSET;
|
|
412
|
+
const index = localRow >= 0 && localRow < this.#visibleCount ? this.#scrollStart + localRow : undefined;
|
|
413
|
+
const target = index !== undefined && index < this.#filteredProviders.length ? index : null;
|
|
414
|
+
if (event.motion) {
|
|
415
|
+
if (target !== this.#hoveredIndex) {
|
|
416
|
+
this.#hoveredIndex = target;
|
|
364
417
|
this.#updateList();
|
|
365
418
|
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (!event.leftClick || target === null) return;
|
|
422
|
+
if (target !== this.#selectedIndex) {
|
|
423
|
+
this.#selectedIndex = target;
|
|
424
|
+
this.#statusMessage = undefined;
|
|
366
425
|
}
|
|
426
|
+
this.#confirmSelection();
|
|
367
427
|
}
|
|
368
428
|
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
fuzzyRank,
|
|
8
8
|
getKeybindings,
|
|
9
9
|
getSettingItemFilterText,
|
|
10
|
+
type ImageBudget,
|
|
10
11
|
Input,
|
|
11
12
|
matchesKey,
|
|
12
13
|
parseSgrMouse,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
truncateToWidth,
|
|
23
24
|
visibleWidth,
|
|
24
25
|
} from "@oh-my-pi/pi-tui";
|
|
26
|
+
import type { ShapeTarget } from "@oh-my-pi/snapcompact";
|
|
25
27
|
import { getDefault, type SettingPath, settings } from "../../config/settings";
|
|
26
28
|
import type {
|
|
27
29
|
SettingTab,
|
|
@@ -36,6 +38,7 @@ import { getTabBarTheme } from "../shared";
|
|
|
36
38
|
import { bottomBorder, divider, row, topBorder } from "./overlay-box";
|
|
37
39
|
import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings";
|
|
38
40
|
import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs";
|
|
41
|
+
import { SnapcompactShapePreview } from "./snapcompact-shape-preview";
|
|
39
42
|
import { getPreset } from "./status-line/presets";
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -97,6 +100,7 @@ class SelectSubmenu extends Container {
|
|
|
97
100
|
onCancel: () => void,
|
|
98
101
|
onSelectionChange?: (value: string) => void | Promise<void>,
|
|
99
102
|
private readonly getPreview?: () => string,
|
|
103
|
+
footer?: Component,
|
|
100
104
|
) {
|
|
101
105
|
super();
|
|
102
106
|
|
|
@@ -158,6 +162,13 @@ class SelectSubmenu extends Container {
|
|
|
158
162
|
// Hint
|
|
159
163
|
this.addChild(new Spacer(1));
|
|
160
164
|
this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to go back"), 0, 0));
|
|
165
|
+
|
|
166
|
+
// Footer (e.g. the snapcompact shape preview) below the interactive rows,
|
|
167
|
+
// so the list never shifts while browsing.
|
|
168
|
+
if (footer) {
|
|
169
|
+
this.addChild(new Spacer(1));
|
|
170
|
+
this.addChild(footer);
|
|
171
|
+
}
|
|
161
172
|
}
|
|
162
173
|
|
|
163
174
|
#updatePreview(): void {
|
|
@@ -249,6 +260,12 @@ export interface SettingsRuntimeContext {
|
|
|
249
260
|
availableThemes: string[];
|
|
250
261
|
/** Working directory for plugins tab */
|
|
251
262
|
cwd: string;
|
|
263
|
+
/** Active model (api + id); resolves what the snapcompact `auto` shape maps to. */
|
|
264
|
+
model?: ShapeTarget;
|
|
265
|
+
/** Shared TUI image budget (graphics ids + transmit-once) for image previews. */
|
|
266
|
+
imageBudget?: ImageBudget;
|
|
267
|
+
/** Schedules a re-render after async preview work completes. */
|
|
268
|
+
requestRender?: () => void;
|
|
252
269
|
}
|
|
253
270
|
|
|
254
271
|
/** Status line settings subset for preview */
|
|
@@ -758,6 +775,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
758
775
|
// Preview handlers
|
|
759
776
|
let onPreview: ((value: string) => void | Promise<void>) | undefined;
|
|
760
777
|
let onPreviewCancel: (() => void) | undefined;
|
|
778
|
+
let footer: Component | undefined;
|
|
761
779
|
|
|
762
780
|
const activeThemeBeforePreview = getCurrentThemeName() ?? currentValue;
|
|
763
781
|
if (def.path === "theme.dark" || def.path === "theme.light") {
|
|
@@ -797,6 +815,14 @@ export class SettingsSelectorComponent implements Component {
|
|
|
797
815
|
const separator = settings.get("statusLine.separator");
|
|
798
816
|
this.callbacks.onStatusLinePreview?.({ separator });
|
|
799
817
|
};
|
|
818
|
+
} else if (def.path === "snapcompact.shape") {
|
|
819
|
+
const shapePreview = new SnapcompactShapePreview(currentValue, {
|
|
820
|
+
model: this.context.model,
|
|
821
|
+
imageBudget: this.context.imageBudget,
|
|
822
|
+
requestRender: this.context.requestRender,
|
|
823
|
+
});
|
|
824
|
+
onPreview = value => shapePreview.setValue(value);
|
|
825
|
+
footer = shapePreview;
|
|
800
826
|
}
|
|
801
827
|
|
|
802
828
|
// Provide status line preview for theme selection
|
|
@@ -819,6 +845,7 @@ export class SettingsSelectorComponent implements Component {
|
|
|
819
845
|
},
|
|
820
846
|
onPreview,
|
|
821
847
|
getPreview,
|
|
848
|
+
footer,
|
|
822
849
|
);
|
|
823
850
|
}
|
|
824
851
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[User]: Fix the settings overlay crash. Wheeling past the last row throws.
|
|
2
|
+
|
|
3
|
+
[Assistant tool calls]: read(path="src/select-list.ts:140-180")
|
|
4
|
+
|
|
5
|
+
[Tool result]: 162: const index = Math.floor(line / rowHeight); index is never checked against bounds.
|
|
6
|
+
|
|
7
|
+
[Assistant]: Found it. The hit test indexes past the filtered list; clamping to the last row fixes the crash.
|
|
8
|
+
|
|
9
|
+
[User]: Does the fix survive filtering?
|
|
10
|
+
|
|
11
|
+
[Assistant]: Yes. The clamp applies after the filter pass, so a narrowed list keeps the hit map in sync. Added a regression test that wheels past the last row with a filter active and asserts no throw.
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live preview for the `snapcompact.shape` setting: renders a sample session
|
|
3
|
+
* transcript through the real snapcompact rasterizer as a miniature page and
|
|
4
|
+
* shows it zoomed, so cell size, ink hues, highlight bands, and dim tool-result
|
|
5
|
+
* spans are legible at terminal scale.
|
|
6
|
+
*
|
|
7
|
+
* The mini-frame (a {@link SRC_FRAME_PX}px page) is upscaled with
|
|
8
|
+
* nearest-neighbor so the glyph pixels stay crisp when the terminal scales the
|
|
9
|
+
* placement box. Graphics display requires the Kitty unicode-placeholder path —
|
|
10
|
+
* `renderImage` returning `lines` is the gate — because the bordered settings
|
|
11
|
+
* frame re-fits every row, which direct cursor-positioned placements (iTerm2,
|
|
12
|
+
* Sixel, Kitty `a=p`) do not survive. Everything else falls back to the stats
|
|
13
|
+
* line plus a dim notice.
|
|
14
|
+
*/
|
|
15
|
+
import { type Component, type ImageBudget, renderImage, TERMINAL } from "@oh-my-pi/pi-tui";
|
|
16
|
+
import {
|
|
17
|
+
DIM_OFF,
|
|
18
|
+
DIM_ON,
|
|
19
|
+
geometry,
|
|
20
|
+
isShapeVariantName,
|
|
21
|
+
normalize,
|
|
22
|
+
renderMany,
|
|
23
|
+
resolveShape,
|
|
24
|
+
SHAPE_VARIANT_NAMES,
|
|
25
|
+
SHAPE_VARIANTS,
|
|
26
|
+
type Shape,
|
|
27
|
+
type ShapeTarget,
|
|
28
|
+
type ShapeVariantName,
|
|
29
|
+
} from "@oh-my-pi/snapcompact";
|
|
30
|
+
import { theme } from "../theme/theme";
|
|
31
|
+
import sampleDoc from "./snapcompact-shape-preview-doc.md" with { type: "text" };
|
|
32
|
+
|
|
33
|
+
/** Mini-frame edge in px — a small page from the real rasterizer ≈ a zoomed crop. */
|
|
34
|
+
const SRC_FRAME_PX = 128;
|
|
35
|
+
/** Nearest-neighbor upscale factor; keeps glyph pixels crisp on HiDPI cell boxes. */
|
|
36
|
+
const ZOOM_SCALE = 4;
|
|
37
|
+
/** Display box in terminal cells (square-ish at the typical 1:2 cell aspect). */
|
|
38
|
+
const MAX_IMAGE_COLS = 28;
|
|
39
|
+
const MAX_IMAGE_ROWS = 14;
|
|
40
|
+
|
|
41
|
+
/** Sample transcript with `[Tool result]:` bodies wrapped in dim-ink toggles. */
|
|
42
|
+
const PREVIEW_TEXT = sampleDoc
|
|
43
|
+
.trim()
|
|
44
|
+
.replace(/\[Tool result\]: ([^[]*)/g, (_match, body: string) => `[Tool result]: ${DIM_ON}${body}${DIM_OFF}`);
|
|
45
|
+
|
|
46
|
+
type PreviewEntry =
|
|
47
|
+
| { state: "rendering" }
|
|
48
|
+
| { state: "failed" }
|
|
49
|
+
| { state: "ready"; data: string; edgePx: number; imageId: number; transmitted: boolean };
|
|
50
|
+
|
|
51
|
+
export interface SnapcompactShapePreviewOptions {
|
|
52
|
+
/** Active model (api + id); resolves what `auto` maps to for this reader. */
|
|
53
|
+
model?: ShapeTarget;
|
|
54
|
+
/** Shared TUI image budget: stable graphics ids, transmit-once, exit cleanup. */
|
|
55
|
+
imageBudget?: ImageBudget;
|
|
56
|
+
/** Schedules a re-render once an async sample render completes. */
|
|
57
|
+
requestRender?: () => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class SnapcompactShapePreview implements Component {
|
|
61
|
+
#model: ShapeTarget | undefined;
|
|
62
|
+
#budget: ImageBudget | undefined;
|
|
63
|
+
#requestRender: () => void;
|
|
64
|
+
#variant: ShapeVariantName | "auto" = "auto";
|
|
65
|
+
#entries = new Map<ShapeVariantName, PreviewEntry>();
|
|
66
|
+
|
|
67
|
+
constructor(currentValue: string, options: SnapcompactShapePreviewOptions = {}) {
|
|
68
|
+
this.#model = options.model;
|
|
69
|
+
this.#budget = options.imageBudget;
|
|
70
|
+
this.#requestRender = options.requestRender ?? (() => {});
|
|
71
|
+
this.setValue(currentValue);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Track the highlighted option; the next render reflects it. */
|
|
75
|
+
setValue(value: string): void {
|
|
76
|
+
this.#variant = isShapeVariantName(value) ? value : "auto";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
render(width: number): readonly string[] {
|
|
80
|
+
const shape = resolveShape(this.#model, this.#variant);
|
|
81
|
+
const name = resolvedVariantName(shape);
|
|
82
|
+
const geo = geometry(shape);
|
|
83
|
+
const label = this.#variant === "auto" ? `auto → ${name}` : name;
|
|
84
|
+
const chars = geo.capacity >= 1000 ? `${(geo.capacity / 1000).toFixed(1)}k` : String(geo.capacity);
|
|
85
|
+
const tokens =
|
|
86
|
+
shape.frameTokenEstimate >= 1000
|
|
87
|
+
? `${(shape.frameTokenEstimate / 1000).toFixed(1)}k`
|
|
88
|
+
: String(shape.frameTokenEstimate);
|
|
89
|
+
const stats = `full frame ${geo.cols}×${geo.rows} cells ≈ ${chars} chars ≈ ${tokens} tokens`;
|
|
90
|
+
const lines: string[] = [theme.fg("muted", ` Sample (zoomed) · ${label} · ${stats}`), ""];
|
|
91
|
+
|
|
92
|
+
if (!this.#budget || !TERMINAL.imageProtocol) {
|
|
93
|
+
lines.push(theme.fg("dim", " (graphic sample needs a Kitty-graphics terminal)"));
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const entry = this.#ensureEntry(name, shape);
|
|
98
|
+
if (entry.state === "rendering") {
|
|
99
|
+
lines.push(theme.fg("dim", " rendering sample…"));
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
if (entry.state === "failed") {
|
|
103
|
+
lines.push(theme.fg("dim", " (sample render failed)"));
|
|
104
|
+
return lines;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = renderImage(
|
|
108
|
+
entry.data,
|
|
109
|
+
{ widthPx: entry.edgePx, heightPx: entry.edgePx },
|
|
110
|
+
{
|
|
111
|
+
maxWidthCells: Math.max(8, Math.min(MAX_IMAGE_COLS, width - 4)),
|
|
112
|
+
maxHeightCells: MAX_IMAGE_ROWS,
|
|
113
|
+
imageId: entry.imageId,
|
|
114
|
+
includeTransmit: !entry.transmitted,
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
// Only the unicode-placeholder path returns text-cell `lines`; cursor-moving
|
|
118
|
+
// placements would corrupt the bordered settings frame, so skip them.
|
|
119
|
+
if (!result?.lines) {
|
|
120
|
+
lines.push(theme.fg("dim", " (graphic sample needs Kitty unicode-placeholder graphics)"));
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
if (result.transmit) {
|
|
124
|
+
this.#budget.enqueueTransmit(entry.imageId, result.transmit);
|
|
125
|
+
entry.transmitted = true;
|
|
126
|
+
}
|
|
127
|
+
for (const line of result.lines) {
|
|
128
|
+
lines.push(` ${line}`);
|
|
129
|
+
}
|
|
130
|
+
return lines;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#ensureEntry(name: ShapeVariantName, shape: Shape): PreviewEntry {
|
|
134
|
+
let entry = this.#entries.get(name);
|
|
135
|
+
if (!entry) {
|
|
136
|
+
entry = { state: "rendering" };
|
|
137
|
+
this.#entries.set(name, entry);
|
|
138
|
+
void this.#buildEntry(name, shape);
|
|
139
|
+
}
|
|
140
|
+
return entry;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #buildEntry(name: ShapeVariantName, shape: Shape): Promise<void> {
|
|
144
|
+
try {
|
|
145
|
+
// Fill the mini-page so every variant shows a fully inked window.
|
|
146
|
+
const capacity = geometry(shape, SRC_FRAME_PX).capacity;
|
|
147
|
+
let text = PREVIEW_TEXT;
|
|
148
|
+
while (normalize(text).length < capacity) {
|
|
149
|
+
text += ` ${PREVIEW_TEXT}`;
|
|
150
|
+
}
|
|
151
|
+
const frame = renderMany(text, { shape, frameSize: SRC_FRAME_PX, maxFrames: 1 })[0];
|
|
152
|
+
if (!frame) throw new Error("empty sample frame");
|
|
153
|
+
const edgePx = SRC_FRAME_PX * ZOOM_SCALE;
|
|
154
|
+
const zoomed = await new Bun.Image(Buffer.from(frame.data, "base64"))
|
|
155
|
+
.resize(edgePx, edgePx, { filter: "nearest" })
|
|
156
|
+
.png()
|
|
157
|
+
.bytes();
|
|
158
|
+
this.#entries.set(name, {
|
|
159
|
+
state: "ready",
|
|
160
|
+
data: zoomed.toBase64(),
|
|
161
|
+
edgePx,
|
|
162
|
+
// Keyed id: reopening settings reuses the id, so data already in the
|
|
163
|
+
// terminal store is never re-transmitted (enqueueTransmit no-ops).
|
|
164
|
+
imageId: this.#budget?.acquireId(`snapshape:${name}:${edgePx}`) ?? 0,
|
|
165
|
+
transmitted: false,
|
|
166
|
+
});
|
|
167
|
+
} catch {
|
|
168
|
+
this.#entries.set(name, { state: "failed" });
|
|
169
|
+
}
|
|
170
|
+
this.#requestRender();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Research name of the concrete geometry `resolveShape` picked (for `auto`). */
|
|
175
|
+
function resolvedVariantName(shape: Shape): ShapeVariantName {
|
|
176
|
+
for (const name of SHAPE_VARIANT_NAMES) {
|
|
177
|
+
const candidate = SHAPE_VARIANTS[name];
|
|
178
|
+
if (
|
|
179
|
+
candidate.font === shape.font &&
|
|
180
|
+
candidate.cellWidth === shape.cellWidth &&
|
|
181
|
+
candidate.cellHeight === shape.cellHeight &&
|
|
182
|
+
candidate.variant === shape.variant &&
|
|
183
|
+
candidate.lineRepeat === shape.lineRepeat &&
|
|
184
|
+
candidate.frameSize === shape.frameSize
|
|
185
|
+
) {
|
|
186
|
+
return name;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// resolveShape only hands out table geometries; the legacy shape is the
|
|
190
|
+
// conservative label if that invariant ever changes.
|
|
191
|
+
return "5x8-sent";
|
|
192
|
+
}
|
|
@@ -591,20 +591,28 @@ export class ToolExecutionComponent extends Container {
|
|
|
591
591
|
|
|
592
592
|
/**
|
|
593
593
|
* Whether this still-live block's settled rows may enter native scrollback
|
|
594
|
-
* (see `FinalizableBlock.isTranscriptBlockCommitStable`).
|
|
595
|
-
*
|
|
596
|
-
* (edit
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
594
|
+
* (see `FinalizableBlock.isTranscriptBlockCommitStable`). Classification is
|
|
595
|
+
* per renderer (`ToolRenderer.provisionalPendingPreview`): tail-window
|
|
596
|
+
* streaming views (edit's streamed-diff tail, bash/ssh command caps, eval
|
|
597
|
+
* cells) are re-anchored top-first by the result render, so promoting
|
|
598
|
+
* their visually static head — e.g. an edit preview idling on its last
|
|
599
|
+
* frame while the apply + LSP pass runs — would strand a stale copy of
|
|
600
|
+
* the call box above the final block the moment the result lands. Every
|
|
601
|
+
* other pending preview streams top-anchored append-shaped rows the
|
|
602
|
+
* result render preserves (a task call's context/assignment markdown, a
|
|
603
|
+
* write's content), so it stays commit-eligible — a call taller than the
|
|
604
|
+
* viewport scrolls into native history mid-stream instead of reading as
|
|
605
|
+
* cut off until the result. Expanded blocks always stream top-anchored
|
|
606
|
+
* (the over-tall write/eval scrollback contract). Displaceable waiting
|
|
603
607
|
* polls are removed wholesale by the next poll and must never commit.
|
|
604
608
|
*/
|
|
605
609
|
isTranscriptBlockCommitStable(): boolean {
|
|
606
610
|
if (this.#displaceable) return false;
|
|
607
|
-
|
|
611
|
+
if (this.#expanded || this.isTranscriptBlockFinalized()) return true;
|
|
612
|
+
if ((this.#tool as { provisionalPendingPreview?: boolean } | undefined)?.provisionalPendingPreview) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
return !toolRenderers[this.#toolName]?.provisionalPendingPreview;
|
|
608
616
|
}
|
|
609
617
|
|
|
610
618
|
/**
|
|
@@ -4,6 +4,7 @@ import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
|
|
|
4
4
|
import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
5
5
|
import { getRoleInfo } from "../../config/model-roles";
|
|
6
6
|
import { isSettingsInitialized, settings } from "../../config/settings";
|
|
7
|
+
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
|
|
7
8
|
import { renderSegmentTrack } from "../../modes/components/segment-track";
|
|
8
9
|
import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
|
|
9
10
|
import { expandEmoticons } from "../../modes/emoji-autocomplete";
|
|
@@ -1039,18 +1040,19 @@ export class InputController {
|
|
|
1039
1040
|
|
|
1040
1041
|
toggleThinkingBlockVisibility(): void {
|
|
1041
1042
|
this.ctx.hideThinkingBlock = !this.ctx.hideThinkingBlock;
|
|
1042
|
-
settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
|
|
1043
|
+
this.ctx.settings.set("hideThinkingBlock", this.ctx.hideThinkingBlock);
|
|
1043
1044
|
this.ctx.session.agent.hideThinkingSummary = this.ctx.hideThinkingBlock;
|
|
1044
1045
|
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1046
|
+
for (const child of this.ctx.chatContainer.children) {
|
|
1047
|
+
if (child instanceof AssistantMessageComponent) {
|
|
1048
|
+
child.setHideThinkingBlock(this.ctx.hideThinkingBlock);
|
|
1049
|
+
child.invalidate();
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1048
1052
|
|
|
1049
|
-
// If streaming, re-add the streaming component with updated visibility and re-render
|
|
1050
1053
|
if (this.ctx.streamingComponent && this.ctx.streamingMessage) {
|
|
1051
1054
|
this.ctx.streamingComponent.setHideThinkingBlock(this.ctx.hideThinkingBlock);
|
|
1052
1055
|
this.ctx.streamingComponent.updateContent(this.ctx.streamingMessage);
|
|
1053
|
-
this.ctx.chatContainer.addChild(this.ctx.streamingComponent);
|
|
1054
1056
|
}
|
|
1055
1057
|
|
|
1056
1058
|
this.ctx.showStatus(`Thinking blocks: ${this.ctx.hideThinkingBlock ? "hidden" : "visible"}`);
|
|
@@ -114,6 +114,9 @@ export class SelectorController {
|
|
|
114
114
|
thinkingLevel: this.ctx.session.thinkingLevel,
|
|
115
115
|
availableThemes,
|
|
116
116
|
cwd: getProjectDir(),
|
|
117
|
+
model: this.ctx.session.model,
|
|
118
|
+
imageBudget: this.ctx.ui.imageBudget,
|
|
119
|
+
requestRender: () => this.ctx.ui.requestRender(),
|
|
117
120
|
},
|
|
118
121
|
{
|
|
119
122
|
onChange: (id, value) => this.handleSettingChange(id, value),
|
|
@@ -313,10 +316,9 @@ export class SelectorController {
|
|
|
313
316
|
for (const child of this.ctx.chatContainer.children) {
|
|
314
317
|
if (child instanceof AssistantMessageComponent) {
|
|
315
318
|
child.setHideThinkingBlock(value as boolean);
|
|
319
|
+
child.invalidate();
|
|
316
320
|
}
|
|
317
321
|
}
|
|
318
|
-
this.ctx.chatContainer.clear();
|
|
319
|
-
this.ctx.rebuildChatFromMessages();
|
|
320
322
|
break;
|
|
321
323
|
case "theme": {
|
|
322
324
|
setTheme(value as string, true).then(result => {
|
|
@@ -1162,6 +1162,30 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1162
1162
|
// of restarting the visible conversation (the LLM context still resets).
|
|
1163
1163
|
const context = this.session.buildTranscriptSessionContext();
|
|
1164
1164
|
this.renderSessionContext(context);
|
|
1165
|
+
// During the pre-streaming window — after `startPendingSubmission` has
|
|
1166
|
+
// optimistically rendered the user's message but before the user
|
|
1167
|
+
// `message_start` event lands it in `session` entries — any rebuild
|
|
1168
|
+
// (e.g. Ctrl+T toggleThinkingBlockVisibility, theme selector) would
|
|
1169
|
+
// otherwise erase the user's just-submitted message until the first
|
|
1170
|
+
// assistant token arrived (#2372). Once `message_start` fires the
|
|
1171
|
+
// signature is cleared by `EventController`, so this replay is a no-op
|
|
1172
|
+
// post-streaming and cannot duplicate.
|
|
1173
|
+
this.#replayOptimisticUserMessage();
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
#replayOptimisticUserMessage(): void {
|
|
1177
|
+
if (!this.optimisticUserMessageSignature) return;
|
|
1178
|
+
const submission = this.#pendingSubmittedInput;
|
|
1179
|
+
if (!submission || submission.cancelled || submission.customType) return;
|
|
1180
|
+
this.addMessageToChat(
|
|
1181
|
+
{
|
|
1182
|
+
role: "user",
|
|
1183
|
+
content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
|
|
1184
|
+
attribution: "user",
|
|
1185
|
+
timestamp: Date.now(),
|
|
1186
|
+
},
|
|
1187
|
+
{ imageLinks: submission.imageLinks },
|
|
1188
|
+
);
|
|
1165
1189
|
}
|
|
1166
1190
|
|
|
1167
1191
|
#formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
|
|
1
|
+
import { type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme";
|
|
3
3
|
import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
|
|
4
4
|
|
|
@@ -29,6 +29,8 @@ class GlyphSceneController implements SetupSceneController {
|
|
|
29
29
|
#selectList: SelectList;
|
|
30
30
|
#previewRequest = 0;
|
|
31
31
|
#committing = false;
|
|
32
|
+
/** Render line where the select list begins. */
|
|
33
|
+
#listRowStart = 0;
|
|
32
34
|
|
|
33
35
|
constructor(private readonly host: SetupSceneHost) {
|
|
34
36
|
this.#selectList = new SelectList(GLYPH_ITEMS, GLYPH_ITEMS.length, getSelectListTheme());
|
|
@@ -60,12 +62,28 @@ class GlyphSceneController implements SetupSceneController {
|
|
|
60
62
|
this.#selectList.handleInput(data);
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/** Wheel moves the highlight (live preview); hover lights the row under the pointer; click confirms it. */
|
|
66
|
+
routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
|
|
67
|
+
if (this.#committing) return;
|
|
68
|
+
if (event.wheel !== null) {
|
|
69
|
+
this.#selectList.handleWheel(event.wheel);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const index = this.#selectList.hitTest(line - this.#listRowStart);
|
|
73
|
+
if (event.motion) {
|
|
74
|
+
this.#selectList.setHoverIndex(index ?? null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (event.leftClick && index !== undefined) {
|
|
78
|
+
this.#selectList.clickItem(index);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
render(width: number): readonly string[] {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
];
|
|
83
|
+
const lines = [theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."), ""];
|
|
84
|
+
this.#listRowStart = lines.length;
|
|
85
|
+
lines.push(...this.#selectList.render(width));
|
|
86
|
+
return lines;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
async #commit(preset: SymbolPreset): Promise<void> {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TabBar } from "@oh-my-pi/pi-tui";
|
|
1
|
+
import { type SgrMouseEvent, TabBar } from "@oh-my-pi/pi-tui";
|
|
2
2
|
import { getTabBarTheme } from "../../shared";
|
|
3
3
|
import { SignInTab } from "./sign-in";
|
|
4
4
|
import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types";
|
|
@@ -16,6 +16,8 @@ class ProvidersSceneController implements SetupSceneController {
|
|
|
16
16
|
|
|
17
17
|
#tabs: SetupTab[];
|
|
18
18
|
#tabBar: TabBar;
|
|
19
|
+
/** Lines the tab bar occupied in the last render (body starts one blank line below). */
|
|
20
|
+
#tabRowCount = 1;
|
|
19
21
|
|
|
20
22
|
constructor(host: SetupSceneHost) {
|
|
21
23
|
this.#tabs = [new SignInTab(host), new WebSearchTab(host)];
|
|
@@ -52,8 +54,40 @@ class ProvidersSceneController implements SetupSceneController {
|
|
|
52
54
|
tab.handleInput(data);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Hit-test mouse reports against the last render: rows inside the tab bar
|
|
59
|
+
* hover/switch tabs (suppressed while the active panel is modal, matching
|
|
60
|
+
* keyboard tab cycling); everything else forwards to the active panel at
|
|
61
|
+
* panel-local coordinates. Wheel always goes to the panel so scrolling
|
|
62
|
+
* works regardless of pointer position.
|
|
63
|
+
*/
|
|
64
|
+
routeMouse(event: SgrMouseEvent, line: number, col: number): void {
|
|
65
|
+
const tab = this.#activeTab();
|
|
66
|
+
if (event.wheel === null && line >= 0 && line < this.#tabRowCount) {
|
|
67
|
+
if (tab.modal) return;
|
|
68
|
+
const hit = this.#tabBar.tabAt(line, col);
|
|
69
|
+
if (event.motion) {
|
|
70
|
+
this.#tabBar.setHoverTab(hit && !hit.muted ? hit.id : null);
|
|
71
|
+
} else if (event.leftClick && hit) {
|
|
72
|
+
this.#tabBar.selectTab(hit.id);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (event.motion) this.#tabBar.setHoverTab(null);
|
|
77
|
+
const bodyLine = line - this.#tabRowCount - 1;
|
|
78
|
+
if (tab.routeMouse) {
|
|
79
|
+
tab.routeMouse(event, bodyLine, col);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (event.wheel !== null && !tab.modal) {
|
|
83
|
+
tab.handleInput(event.wheel === -1 ? "\x1b[A" : "\x1b[B");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
55
87
|
render(width: number): readonly string[] {
|
|
56
|
-
|
|
88
|
+
const tabLines = this.#tabBar.render(width);
|
|
89
|
+
this.#tabRowCount = tabLines.length;
|
|
90
|
+
return [...tabLines, "", ...this.#activeTab().render(width)];
|
|
57
91
|
}
|
|
58
92
|
|
|
59
93
|
dispose(): void {
|