@oh-my-pi/pi-tui 18.4.4 → 18.4.6
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 +46 -0
- package/dist/types/apps/git/sidebar.d.ts +2 -2
- package/dist/types/chat/recap-notice.d.ts +13 -0
- package/dist/types/components/editor.d.ts +15 -3
- package/dist/types/components/input.d.ts +11 -1
- package/dist/types/native/blobs.d.ts +1 -1
- package/dist/types/native/node.d.ts +63 -1
- package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -5
- package/dist/types/overlays/copy-selector.d.ts +0 -23
- package/dist/types/overlays/error-banner.d.ts +5 -2
- package/dist/types/overlays/hook-editor.d.ts +12 -0
- package/dist/types/overlays/jobs-panel.d.ts +25 -1
- package/dist/types/overlays/model-hub.d.ts +2 -0
- package/dist/types/overlays/rewind-selector.d.ts +17 -4
- package/dist/types/prompt/custom-editor.d.ts +16 -8
- package/dist/types/prompt/welcome.d.ts +3 -3
- package/dist/types/render/render-utils.d.ts +5 -0
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/status-line/metrics.d.ts +4 -0
- package/dist/types/status-line/types.d.ts +13 -2
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/task.d.ts +7 -0
- package/dist/types/tui.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/git/sidebar.ts +15 -2
- package/src/chat/assistant-message.ts +2 -2
- package/src/chat/recap-notice.ts +30 -0
- package/src/chat/tool-execution.ts +19 -3
- package/src/chat/user-message.ts +2 -1
- package/src/components/editor.ts +115 -16
- package/src/components/input.ts +57 -1
- package/src/native/backend.ts +28 -10
- package/src/native/blobs.ts +1 -1
- package/src/native/encode.ts +1 -0
- package/src/native/node.ts +108 -1
- package/src/native/reconcile.ts +5 -4
- package/src/overlays/agent-hub-projection.ts +25 -0
- package/src/overlays/agent-hub.ts +4 -15
- package/src/overlays/ask-dialog.ts +20 -32
- package/src/overlays/copy-selector.ts +6 -6
- package/src/overlays/error-banner.ts +14 -3
- package/src/overlays/hook-editor.ts +59 -6
- package/src/overlays/jobs-panel.ts +132 -49
- package/src/overlays/model-browser.ts +56 -12
- package/src/overlays/model-hub.ts +82 -29
- package/src/overlays/rewind-selector.ts +235 -332
- package/src/overlays/session-selector.ts +19 -20
- package/src/prompt/custom-editor.ts +119 -30
- package/src/prompt/welcome.ts +4 -33
- package/src/render/render-utils.ts +51 -3
- package/src/status-line/component.ts +61 -30
- package/src/status-line/host.ts +2 -0
- package/src/status-line/metrics.ts +36 -5
- package/src/status-line/segments.ts +9 -1
- package/src/status-line/types.ts +13 -2
- package/src/terminal.ts +27 -6
- package/src/tools/edit.ts +23 -0
- package/src/tools/eval.ts +43 -11
- package/src/tools/grouped-file-output.ts +6 -0
- package/src/tools/task.ts +29 -4
- package/src/tui.ts +9 -1
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* caller's text report; natively
|
|
4
|
-
*
|
|
5
|
-
* the others
|
|
2
|
+
* The async background jobs of a session, twice: {@link JobsPanel} is the
|
|
3
|
+
* `/jobs` transcript block (ANSI keeps the caller's text report; natively one
|
|
4
|
+
* frame), {@link JobsSheet} the dismissable sheet the native jobs pill opens.
|
|
5
|
+
* Both draw task jobs as `agent` nodes and the others as a status dot, the
|
|
6
|
+
* label and a live `elapsed` (running first, recent in their own section).
|
|
6
7
|
*/
|
|
7
8
|
import type { TspAgentProps, TspSpan } from "@oh-my-pi/pi-wire";
|
|
9
|
+
import { formatDuration } from "@oh-my-pi/pi-utils";
|
|
8
10
|
import { type Component, Container } from "../tui";
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
+
import { matchesSelectCancel } from "../keybinding-matchers";
|
|
12
|
+
import { truncateToWidth } from "../utils";
|
|
13
|
+
import { card, col, elapsed, node, span, text } from "../native/describe";
|
|
14
|
+
import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
|
|
15
|
+
import { actionBar, actionButton } from "../native/overlay";
|
|
11
16
|
|
|
12
17
|
/** One async job as the session snapshots it. */
|
|
13
18
|
export interface JobsPanelJob {
|
|
@@ -57,56 +62,134 @@ export class JobsPanel extends Container {
|
|
|
57
62
|
override describe(cx: DescribeContext): NativeNode {
|
|
58
63
|
const agent = cx.supports("agent");
|
|
59
64
|
if (this.#native?.agent === agent) return this.#native.node;
|
|
60
|
-
const { running, recent } = this.#snapshot;
|
|
61
|
-
const children: NativeChild[] = [];
|
|
62
|
-
if (running.length === 0 && recent.length === 0) {
|
|
63
|
-
children.push(node("text", { text: "No background jobs", role: "omp.jobs.empty" }, undefined, "empty"));
|
|
64
|
-
}
|
|
65
|
-
for (const job of running) children.push(this.#describeJob(job, agent));
|
|
66
|
-
if (recent.length > 0) {
|
|
67
|
-
children.push(
|
|
68
|
-
node(
|
|
69
|
-
"section",
|
|
70
|
-
{ head: [span("Recent", "muted")] },
|
|
71
|
-
recent.map(job => this.#describeJob(job, agent)),
|
|
72
|
-
"recent",
|
|
73
|
-
),
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
65
|
const head: TspSpan[] = [span("Background jobs", "strong")];
|
|
77
|
-
|
|
78
|
-
|
|
66
|
+
const running = this.#snapshot.running.length;
|
|
67
|
+
if (running > 0) head.push(span(` · ${running} running`, "muted"));
|
|
68
|
+
const described = card({ role: "omp.jobs", head }, describeJobs(this.#snapshot, this.#nowMs, agent));
|
|
79
69
|
this.#native = { agent, node: described };
|
|
80
70
|
return described;
|
|
81
71
|
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The jobs pill's sheet: a centred `md` glass sheet titled Background jobs
|
|
76
|
+
* that re-reads the session's jobs on every frame, so rows start, settle and
|
|
77
|
+
* move to Recent while it is open. Esc or Close dismisses it; nothing enters
|
|
78
|
+
* the transcript.
|
|
79
|
+
*/
|
|
80
|
+
export class JobsSheet implements Component {
|
|
81
|
+
readonly nativeOverlay = {
|
|
82
|
+
role: "omp.overlay.jobs",
|
|
83
|
+
size: "md",
|
|
84
|
+
anchor: "center",
|
|
85
|
+
head: "Background jobs",
|
|
86
|
+
} as const;
|
|
87
|
+
readonly #load: () => JobsPanelSnapshot;
|
|
88
|
+
readonly #onClose: () => void;
|
|
89
|
+
#native: { key: string; node: NativeNode } | undefined;
|
|
90
|
+
|
|
91
|
+
/** `load` returns the current jobs; it runs once per frame. */
|
|
92
|
+
constructor(load: () => JobsPanelSnapshot, onClose: () => void) {
|
|
93
|
+
this.#load = load;
|
|
94
|
+
this.#onClose = onClose;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
describe(cx: DescribeContext): NativeNode {
|
|
98
|
+
const agent = cx.supports("agent");
|
|
99
|
+
const snapshot = this.#load();
|
|
100
|
+
// Elapsed nodes tick on their own; only a job starting, settling or leaving redraws.
|
|
101
|
+
const key = `${agent}|${jobsKey(snapshot.running)}|${jobsKey(snapshot.recent)}`;
|
|
102
|
+
if (this.#native?.key === key) return this.#native.node;
|
|
103
|
+
const close = actionButton("Close", "close", { keys: "escape" });
|
|
104
|
+
const described = col([...describeJobs(snapshot, Date.now(), agent), actionBar([null, close])], {
|
|
105
|
+
gap: "md",
|
|
106
|
+
});
|
|
107
|
+
this.#native = { key, node: described };
|
|
108
|
+
return described;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Rows for a terminal without the native surface: status, label, type and age per job. */
|
|
112
|
+
render(width: number): readonly string[] {
|
|
113
|
+
const { running, recent } = this.#load();
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
const line = (job: JobsPanelJob): string => {
|
|
116
|
+
const age = formatDuration(Math.max(0, (job.endTime ?? now) - job.startTime));
|
|
117
|
+
return truncateToWidth(` ${job.status} ${job.label} ${job.type} ${age}`, width);
|
|
118
|
+
};
|
|
119
|
+
const lines = [truncateToWidth(` Background jobs · ${running.length} running`, width)];
|
|
120
|
+
if (running.length === 0 && recent.length === 0) lines.push(" No background jobs");
|
|
121
|
+
lines.push(...running.map(line));
|
|
122
|
+
if (recent.length > 0) lines.push(" Recent", ...recent.map(line));
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
invalidate(): void {
|
|
127
|
+
this.#native = undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
handleInput(data: string): void {
|
|
131
|
+
if (matchesSelectCancel(data)) this.#onClose();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Close runs what Esc runs. */
|
|
135
|
+
handleNativeEvent(event: NativeUiEvent): void {
|
|
136
|
+
if (event.type === "action" && event.act === "close") this.#onClose();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Identity of a job list as drawn: ids and statuses in order. */
|
|
141
|
+
function jobsKey(jobs: readonly JobsPanelJob[]): string {
|
|
142
|
+
return jobs.map(job => `${job.id}:${job.status}`).join(",");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The body both views share: running rows, then a Recent section; an empty note when there are none. */
|
|
146
|
+
function describeJobs(snapshot: JobsPanelSnapshot, nowMs: number, agent: boolean): NativeChild[] {
|
|
147
|
+
const { running, recent } = snapshot;
|
|
148
|
+
const children: NativeChild[] = [];
|
|
149
|
+
if (running.length === 0 && recent.length === 0) {
|
|
150
|
+
children.push(node("text", { text: "No background jobs", role: "omp.jobs.empty" }, undefined, "empty"));
|
|
151
|
+
}
|
|
152
|
+
for (const job of running) children.push(describeJob(job, nowMs, agent));
|
|
153
|
+
if (recent.length > 0) {
|
|
154
|
+
children.push(
|
|
155
|
+
node(
|
|
156
|
+
"section",
|
|
157
|
+
{ head: [span("Recent", "muted")] },
|
|
158
|
+
recent.map(job => describeJob(job, nowMs, agent)),
|
|
159
|
+
"recent",
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return children;
|
|
164
|
+
}
|
|
82
165
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"agent",
|
|
89
|
-
{
|
|
90
|
-
name: job.agentId ?? job.id,
|
|
91
|
-
agent: "task",
|
|
92
|
-
task: job.label,
|
|
93
|
-
status: AGENT_STATUS[job.status],
|
|
94
|
-
stats: settled ? { took: age } : { age },
|
|
95
|
-
},
|
|
96
|
-
undefined,
|
|
97
|
-
job.id,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
166
|
+
/** A task job as an `agent` node when the terminal draws them, else a dot row. */
|
|
167
|
+
function describeJob(job: JobsPanelJob, nowMs: number, agent: boolean): NativeNode {
|
|
168
|
+
const settled = job.status !== "running";
|
|
169
|
+
const age = Math.max(0, (job.endTime ?? nowMs) - job.startTime);
|
|
170
|
+
if (agent && job.type === "task") {
|
|
100
171
|
return node(
|
|
101
|
-
"
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
172
|
+
"agent",
|
|
173
|
+
{
|
|
174
|
+
name: job.agentId ?? job.id,
|
|
175
|
+
agent: "task",
|
|
176
|
+
task: job.label,
|
|
177
|
+
status: AGENT_STATUS[job.status],
|
|
178
|
+
stats: settled ? { took: age } : { age },
|
|
179
|
+
},
|
|
180
|
+
undefined,
|
|
109
181
|
job.id,
|
|
110
182
|
);
|
|
111
183
|
}
|
|
184
|
+
return node(
|
|
185
|
+
"row",
|
|
186
|
+
{ role: "omp.jobs.row", gap: "sm", align: "center", title: `${job.id} · ${job.type} · ${job.status}` },
|
|
187
|
+
[
|
|
188
|
+
text([DOT[job.status]], { aria: job.status }),
|
|
189
|
+
text([span(job.label)], { truncate: "end", grow: 1 }),
|
|
190
|
+
text([span(job.type, "dim")]),
|
|
191
|
+
elapsed(age, settled),
|
|
192
|
+
],
|
|
193
|
+
job.id,
|
|
194
|
+
);
|
|
112
195
|
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
11
11
|
import type { Model } from "@oh-my-pi/pi-ai";
|
|
12
12
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
13
|
-
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
14
|
-
import type { ModelKind } from "@oh-my-pi/pi-catalog/types";
|
|
13
|
+
import { getModelPricingStatus, modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
14
|
+
import type { ModelKind, ModelPricingStatus } from "@oh-my-pi/pi-catalog/types";
|
|
15
15
|
import type { Component } from "../tui";
|
|
16
16
|
import { fuzzyRank } from "../fuzzy";
|
|
17
17
|
import { Input } from "../components/input";
|
|
@@ -619,10 +619,23 @@ function roleChipSpans(role: string, assignment: RoleAssignment, settings: Model
|
|
|
619
619
|
return spans;
|
|
620
620
|
}
|
|
621
621
|
|
|
622
|
-
/**
|
|
622
|
+
/**
|
|
623
|
+
* The non-rate pricing state a catalog rule declared for a zero-rate row
|
|
624
|
+
* (`pricing-status`), or `undefined` when the row publishes rates or declares
|
|
625
|
+
* nothing. Undeclared zero-rate rows keep the `free` convention below.
|
|
626
|
+
*/
|
|
627
|
+
function declaredPricingStatus(model: Model): Exclude<ModelPricingStatus, "fixed"> | undefined {
|
|
628
|
+
if (model.pricingStatus === undefined) return undefined;
|
|
629
|
+
const status = getModelPricingStatus(model);
|
|
630
|
+
return status === "fixed" ? undefined : status;
|
|
631
|
+
}
|
|
632
|
+
/** No token price and no subscription-credit charge (unless a pricing state is declared). */
|
|
623
633
|
function isFreeModel(model: Model): boolean {
|
|
634
|
+
const declared = declaredPricingStatus(model);
|
|
635
|
+
if (declared !== undefined) return declared === "free";
|
|
624
636
|
const cost = model.cost;
|
|
625
|
-
|
|
637
|
+
const credits = model.factoryDroidCredits;
|
|
638
|
+
return (!cost || (cost.input === 0 && cost.output === 0)) && (credits === undefined || credits === 0);
|
|
626
639
|
}
|
|
627
640
|
|
|
628
641
|
/** One per-million price leg: `3`, `0.25`, `12.5`; `?` when unknown. */
|
|
@@ -635,10 +648,37 @@ function formatCostLeg(n: number): string {
|
|
|
635
648
|
return s.includes(".") ? s.replace(/\.?0+$/, "") : s;
|
|
636
649
|
}
|
|
637
650
|
|
|
638
|
-
/**
|
|
651
|
+
/**
|
|
652
|
+
* Adds Factory Droid's `N×` base Standard Credits rate to a dollar price,
|
|
653
|
+
* replacing the price when the model has no dollar reference. Neither the
|
|
654
|
+
* reference price nor the base credit rate includes live promotions.
|
|
655
|
+
*/
|
|
656
|
+
function withCreditBadge(model: Model, price: string): string {
|
|
657
|
+
const credits = model.factoryDroidCredits;
|
|
658
|
+
if (credits === undefined) return price;
|
|
659
|
+
const badge = `${formatCostLeg(credits)}×`;
|
|
660
|
+
return model.cost.input !== 0 || model.cost.output !== 0 ? `${price} ${badge}` : badge;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const PRICING_STATUS_LABELS: Record<Exclude<ModelPricingStatus, "fixed">, { short: string; detail: string }> = {
|
|
664
|
+
free: { short: "free", detail: "free" },
|
|
665
|
+
included: { short: "included", detail: "included" },
|
|
666
|
+
variable: { short: "varies", detail: "price varies" },
|
|
667
|
+
unknown: { short: "unknown", detail: "pricing unknown" },
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
/** `$in/out` per-million cost pair with any credit badge; `free` when nothing is charged; a declared pricing state otherwise. */
|
|
639
671
|
function formatCostPair(model: Model): string {
|
|
672
|
+
const declared = declaredPricingStatus(model);
|
|
673
|
+
if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
|
|
640
674
|
if (isFreeModel(model)) return "free";
|
|
641
|
-
return `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}
|
|
675
|
+
return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}/${formatCostLeg(model.cost.output)}`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Detail-pane price fact: `$3/15 per M`, or the declared pricing state in words. */
|
|
679
|
+
function formatCostDetail(model: Model): string {
|
|
680
|
+
const declared = declaredPricingStatus(model);
|
|
681
|
+
return declared !== undefined ? PRICING_STATUS_LABELS[declared].detail : `${formatCostPair(model)} per M`;
|
|
642
682
|
}
|
|
643
683
|
|
|
644
684
|
/** Fact columns of a model picker (Stencil `NATIVE_REDESIGN.md` §4.7); the lowest priority hides first. */
|
|
@@ -649,18 +689,22 @@ export const MODEL_PICKER_COLUMNS: readonly TspPickerColumn[] = [
|
|
|
649
689
|
{ id: "price", head: "$/M", format: "price", priority: 3 },
|
|
650
690
|
];
|
|
651
691
|
|
|
652
|
-
/** `$3·15` price fact of a picker row
|
|
692
|
+
/** `$3·15` price fact of a picker row; `free` at zero cost; a declared pricing state otherwise. */
|
|
653
693
|
function pickerPrice(model: Model): string {
|
|
694
|
+
const declared = declaredPricingStatus(model);
|
|
695
|
+
if (declared !== undefined) return PRICING_STATUS_LABELS[declared].short;
|
|
654
696
|
if (isFreeModel(model)) return "free";
|
|
655
|
-
return `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}
|
|
697
|
+
return withCreditBadge(model, `$${formatCostLeg(model.cost.input)}·${formatCostLeg(model.cost.output)}`);
|
|
656
698
|
}
|
|
657
699
|
|
|
658
700
|
/** `$2 in · $10 out · $0.2 cache` for a model preview. */
|
|
659
701
|
function previewPrice(model: Model): string {
|
|
702
|
+
const declared = declaredPricingStatus(model);
|
|
703
|
+
if (declared !== undefined) return PRICING_STATUS_LABELS[declared].detail;
|
|
660
704
|
const cost = model.cost;
|
|
661
705
|
const parts = [`$${formatCostLeg(cost.input)} in`, `$${formatCostLeg(cost.output)} out`];
|
|
662
706
|
if (cost.cacheRead > 0) parts.push(`$${formatCostLeg(cost.cacheRead)} cache`);
|
|
663
|
-
return parts.join(" · ");
|
|
707
|
+
return withCreditBadge(model, parts.join(" · "));
|
|
664
708
|
}
|
|
665
709
|
|
|
666
710
|
/** The omp theme token of a thinking level's dot (`thinkingHigh`); none for inherit and auto. */
|
|
@@ -1301,7 +1345,7 @@ export class ModelBrowser implements Component {
|
|
|
1301
1345
|
if (model.isRecommended) facts.push("recommended");
|
|
1302
1346
|
if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
|
|
1303
1347
|
if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
|
|
1304
|
-
facts.push(
|
|
1348
|
+
facts.push(formatCostDetail(model));
|
|
1305
1349
|
if (model.reasoning) facts.push("reasoning");
|
|
1306
1350
|
if (model.input.includes("image")) facts.push("vision");
|
|
1307
1351
|
const intelligence = formatIntelligence(model);
|
|
@@ -1564,7 +1608,7 @@ export class ModelBrowser implements Component {
|
|
|
1564
1608
|
const facts: string[] = [];
|
|
1565
1609
|
if (model.contextWindow) facts.push(`${formatNumber(model.contextWindow).toLowerCase()} ctx`);
|
|
1566
1610
|
if (model.maxTokens) facts.push(`${formatNumber(model.maxTokens).toLowerCase()} out`);
|
|
1567
|
-
facts.push(
|
|
1611
|
+
facts.push(formatCostDetail(model));
|
|
1568
1612
|
if (model.reasoning) facts.push("reasoning");
|
|
1569
1613
|
if (model.input.includes("image")) facts.push("vision");
|
|
1570
1614
|
const intelligence = formatIntelligence(model);
|
|
@@ -1881,7 +1925,7 @@ export class ModelBrowser implements Component {
|
|
|
1881
1925
|
const facts: { k: TspText; v: TspText }[] = [];
|
|
1882
1926
|
if (ctx > 0) facts.push({ k: "ctx", v: formatNumber(ctx).toLowerCase() });
|
|
1883
1927
|
if (out > 0) facts.push({ k: "out", v: formatNumber(out).toLowerCase() });
|
|
1884
|
-
facts.push({ k: "price", v: isFreeModel(model) ? "free" :
|
|
1928
|
+
facts.push({ k: "price", v: isFreeModel(model) ? "free" : formatCostDetail(model) });
|
|
1885
1929
|
facts.push({ k: "reasoning", v: model.reasoning ? "yes" : "no" });
|
|
1886
1930
|
const children: NativeChild[] = [node("kv", { items: facts, layout: "inline" })];
|
|
1887
1931
|
const chips: NativeChild[] = [];
|
|
@@ -152,6 +152,8 @@ export interface ModelHubCallbacks {
|
|
|
152
152
|
onFallbackChainChange?: (role: string, chain: string[]) => void;
|
|
153
153
|
/** Locked provider activation: forward to the /login flow. */
|
|
154
154
|
onLoginRequest?: (providerId: string) => void;
|
|
155
|
+
/** Save the current role assignments and default thinking level as a named model preset. */
|
|
156
|
+
onSavePreset?: (name: string) => void;
|
|
155
157
|
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
|
|
156
158
|
onCycleOrderChange?: (order: string[]) => void;
|
|
157
159
|
onCancel: () => void;
|
|
@@ -204,13 +206,14 @@ type StripState =
|
|
|
204
206
|
initialThinkingLevel?: ConfiguredThinkingLevel;
|
|
205
207
|
})
|
|
206
208
|
| {
|
|
207
|
-
/** Footer text input naming a new
|
|
208
|
-
kind: "
|
|
209
|
+
/** Footer text input naming a new role, or saving a model preset. */
|
|
210
|
+
kind: "name";
|
|
211
|
+
purpose: "role" | "preset";
|
|
209
212
|
input: Input;
|
|
210
213
|
};
|
|
211
214
|
|
|
212
215
|
/** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
|
|
213
|
-
type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking";
|
|
216
|
+
type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking" | "save";
|
|
214
217
|
|
|
215
218
|
/** Printable keys of the Roles view and the command each runs. */
|
|
216
219
|
const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
|
|
@@ -221,6 +224,7 @@ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
|
|
|
221
224
|
"]": "later",
|
|
222
225
|
n: "new",
|
|
223
226
|
t: "thinking",
|
|
227
|
+
s: "save",
|
|
224
228
|
};
|
|
225
229
|
|
|
226
230
|
/** Picker fact columns of the Roles view. */
|
|
@@ -1429,7 +1433,7 @@ export class ModelHubComponent implements Component {
|
|
|
1429
1433
|
|
|
1430
1434
|
#activateStripChip(): void {
|
|
1431
1435
|
const strip = this.#strip;
|
|
1432
|
-
if (!strip || strip.kind === "
|
|
1436
|
+
if (!strip || strip.kind === "name") return;
|
|
1433
1437
|
const chip = strip.chips[strip.index];
|
|
1434
1438
|
if (!chip) return;
|
|
1435
1439
|
switch (chip.action) {
|
|
@@ -1654,15 +1658,26 @@ export class ModelHubComponent implements Component {
|
|
|
1654
1658
|
this.#refreshAfterMutation();
|
|
1655
1659
|
}
|
|
1656
1660
|
|
|
1657
|
-
/** Open the footer name input
|
|
1658
|
-
#
|
|
1659
|
-
this.#strip = { kind: "
|
|
1661
|
+
/** Open the footer name input: a new custom role, or saving the current setup as a model preset. */
|
|
1662
|
+
#openNameStrip(purpose: "role" | "preset"): void {
|
|
1663
|
+
this.#strip = { kind: "name", purpose, input: new Input() };
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
/** Validate and commit the name strip: a new role jumps into assigning it, a preset name saves it. */
|
|
1667
|
+
#submitNameStrip(): void {
|
|
1668
|
+
const strip = this.#strip;
|
|
1669
|
+
if (strip?.kind !== "name") return;
|
|
1670
|
+
if (strip.purpose === "preset") {
|
|
1671
|
+
this.#submitPresetName();
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
this.#submitRoleName();
|
|
1660
1675
|
}
|
|
1661
1676
|
|
|
1662
1677
|
/** Validate and commit the new-role name: jump straight into assigning it. */
|
|
1663
1678
|
#submitRoleName(): void {
|
|
1664
1679
|
const strip = this.#strip;
|
|
1665
|
-
if (strip?.kind !== "
|
|
1680
|
+
if (strip?.kind !== "name" || strip.purpose !== "role") return;
|
|
1666
1681
|
const name = strip.input.getValue().trim();
|
|
1667
1682
|
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
|
|
1668
1683
|
if (this.#visibleRoleIds().includes(name)) return;
|
|
@@ -1671,6 +1686,18 @@ export class ModelHubComponent implements Component {
|
|
|
1671
1686
|
this.#startAssign(name);
|
|
1672
1687
|
}
|
|
1673
1688
|
|
|
1689
|
+
/** Validate and commit the preset name: save the current setup under it (overwrites). */
|
|
1690
|
+
#submitPresetName(): void {
|
|
1691
|
+
const strip = this.#strip;
|
|
1692
|
+
if (strip?.kind !== "name" || strip.purpose !== "preset") return;
|
|
1693
|
+
const name = strip.input.getValue().trim();
|
|
1694
|
+
if (!/^[a-zA-Z][\w-]*$/.test(name)) return;
|
|
1695
|
+
this.#strip = null;
|
|
1696
|
+
this.#frame.chipRanges = [];
|
|
1697
|
+
this.#callbacks.onSavePreset?.(name);
|
|
1698
|
+
this.#refreshAfterMutation();
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1674
1701
|
// ═══════════════════════════════════════════════════════════════════════
|
|
1675
1702
|
// Input
|
|
1676
1703
|
// ═══════════════════════════════════════════════════════════════════════
|
|
@@ -1841,9 +1868,9 @@ export class ModelHubComponent implements Component {
|
|
|
1841
1868
|
this.#closeStrip();
|
|
1842
1869
|
return;
|
|
1843
1870
|
}
|
|
1844
|
-
if (strip.kind === "
|
|
1871
|
+
if (strip.kind === "name") {
|
|
1845
1872
|
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
1846
|
-
this.#
|
|
1873
|
+
this.#submitNameStrip();
|
|
1847
1874
|
return;
|
|
1848
1875
|
}
|
|
1849
1876
|
strip.input.handleInput(data);
|
|
@@ -1895,7 +1922,7 @@ export class ModelHubComponent implements Component {
|
|
|
1895
1922
|
this.#startAssignFallbackKey();
|
|
1896
1923
|
return;
|
|
1897
1924
|
case "newRole":
|
|
1898
|
-
this.#
|
|
1925
|
+
this.#openNameStrip("role");
|
|
1899
1926
|
return;
|
|
1900
1927
|
case "separator":
|
|
1901
1928
|
return;
|
|
@@ -2001,7 +2028,10 @@ export class ModelHubComponent implements Component {
|
|
|
2001
2028
|
return;
|
|
2002
2029
|
}
|
|
2003
2030
|
case "new":
|
|
2004
|
-
this.#
|
|
2031
|
+
this.#openNameStrip("role");
|
|
2032
|
+
return;
|
|
2033
|
+
case "save":
|
|
2034
|
+
if (this.#callbacks.onSavePreset) this.#openNameStrip("preset");
|
|
2005
2035
|
return;
|
|
2006
2036
|
case "thinking":
|
|
2007
2037
|
if (role) {
|
|
@@ -2036,7 +2066,7 @@ export class ModelHubComponent implements Component {
|
|
|
2036
2066
|
// Footer strip chips (columns stay in frame coordinates).
|
|
2037
2067
|
if (footerColumn !== undefined && this.#strip) {
|
|
2038
2068
|
const strip = this.#strip;
|
|
2039
|
-
if (event.leftClick && strip.kind !== "
|
|
2069
|
+
if (event.leftClick && strip.kind !== "name" && this.#frame.selectChipAt(strip, footerColumn)) {
|
|
2040
2070
|
this.#activateStripChip();
|
|
2041
2071
|
}
|
|
2042
2072
|
return true;
|
|
@@ -2472,7 +2502,10 @@ export class ModelHubComponent implements Component {
|
|
|
2472
2502
|
const altLeftRight = formatKeyHints(["alt+left", "alt+right"]);
|
|
2473
2503
|
const strip = this.#strip;
|
|
2474
2504
|
if (strip) {
|
|
2475
|
-
if (strip.kind === "
|
|
2505
|
+
if (strip.kind === "name") {
|
|
2506
|
+
if (strip.purpose === "preset") {
|
|
2507
|
+
return `${enter} save preset · ${cancel} cancel`;
|
|
2508
|
+
}
|
|
2476
2509
|
return `${enter} create + pick model · ${cancel} cancel`;
|
|
2477
2510
|
}
|
|
2478
2511
|
if (strip.kind === "role") return `${leftRight} choose · ${enter} assign/clear · ${cancel} cancel`;
|
|
@@ -2517,7 +2550,8 @@ export class ModelHubComponent implements Component {
|
|
|
2517
2550
|
// open — an assigned role whose model has thinking levels to offer.
|
|
2518
2551
|
const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
|
|
2519
2552
|
const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
|
|
2520
|
-
|
|
2553
|
+
const savePreset = this.#callbacks.onSavePreset ? ` · ${formatKeyHint("s")} save preset` : "";
|
|
2554
|
+
return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}`;
|
|
2521
2555
|
}
|
|
2522
2556
|
if (entry.kind === "provider" && entry.locked) {
|
|
2523
2557
|
return entry.oauth
|
|
@@ -2541,9 +2575,11 @@ export class ModelHubComponent implements Component {
|
|
|
2541
2575
|
}
|
|
2542
2576
|
|
|
2543
2577
|
#renderStrip(width: number, strip: StripState): string {
|
|
2544
|
-
if (strip.kind === "
|
|
2545
|
-
const
|
|
2546
|
-
const
|
|
2578
|
+
if (strip.kind === "name") {
|
|
2579
|
+
const preset = strip.purpose === "preset";
|
|
2580
|
+
const labelText = preset ? "Preset name:" : "New role name:";
|
|
2581
|
+
const label = theme.fg("accent", labelText);
|
|
2582
|
+
const inputWidth = Math.max(8, Math.min(32, width - visibleWidth(labelText) - 24));
|
|
2547
2583
|
const inputLine = strip.input.render(inputWidth)[0] ?? "";
|
|
2548
2584
|
return truncateToWidth(`${label} ${inputLine} ${theme.fg("dim", "(letters, digits, - and _)")}`, width);
|
|
2549
2585
|
}
|
|
@@ -2668,7 +2704,7 @@ export class ModelHubComponent implements Component {
|
|
|
2668
2704
|
// A chip click picks and applies it, like the footer mouse path.
|
|
2669
2705
|
const strip = this.#strip;
|
|
2670
2706
|
const index = Number(event.item);
|
|
2671
|
-
if (!strip || strip.kind === "
|
|
2707
|
+
if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
|
|
2672
2708
|
strip.index = index;
|
|
2673
2709
|
this.#activateStripChip();
|
|
2674
2710
|
break;
|
|
@@ -3184,8 +3220,13 @@ export class ModelHubComponent implements Component {
|
|
|
3184
3220
|
if (this.#assignmentPending) return [CLOSE_ACTION];
|
|
3185
3221
|
if (strip) {
|
|
3186
3222
|
const apply =
|
|
3187
|
-
strip.kind === "
|
|
3188
|
-
? pickerAction(
|
|
3223
|
+
strip.kind === "name"
|
|
3224
|
+
? pickerAction(
|
|
3225
|
+
strip.purpose === "preset" ? "presetName" : "roleName",
|
|
3226
|
+
strip.purpose === "preset" ? "Save preset" : "Create role",
|
|
3227
|
+
"enter",
|
|
3228
|
+
{ primary: true },
|
|
3229
|
+
)
|
|
3189
3230
|
: pickerAction(
|
|
3190
3231
|
"stripApply",
|
|
3191
3232
|
strip.kind === "thinking" ? "Apply" : strip.kind === "scope" ? "Save to scope" : "Assign / clear",
|
|
@@ -3220,6 +3261,7 @@ export class ModelHubComponent implements Component {
|
|
|
3220
3261
|
this.#roleThinkingTarget(row.role) ? roleAction("thinking", "Thinking", "t") : undefined,
|
|
3221
3262
|
roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
|
|
3222
3263
|
roleAction("new", "New role", "n"),
|
|
3264
|
+
this.#callbacks.onSavePreset ? roleAction("save", "Save preset", "s") : undefined,
|
|
3223
3265
|
);
|
|
3224
3266
|
break;
|
|
3225
3267
|
}
|
|
@@ -3266,9 +3308,13 @@ export class ModelHubComponent implements Component {
|
|
|
3266
3308
|
|
|
3267
3309
|
/** The open strip as the picker's chip strip (role assignment, save scope, thinking level, new role name). */
|
|
3268
3310
|
#pickerStrip(strip: StripState): NonNullable<TspPickerProps["strip"]> {
|
|
3269
|
-
if (strip.kind === "
|
|
3311
|
+
if (strip.kind === "name") {
|
|
3270
3312
|
return {
|
|
3271
|
-
label: [
|
|
3313
|
+
label: [
|
|
3314
|
+
span(strip.purpose === "preset" ? "Preset name " : "New role name ", "muted"),
|
|
3315
|
+
span(strip.input.getValue(), "mono"),
|
|
3316
|
+
span("▏", "accent"),
|
|
3317
|
+
],
|
|
3272
3318
|
items: [],
|
|
3273
3319
|
};
|
|
3274
3320
|
}
|
|
@@ -3562,7 +3608,7 @@ export class ModelHubComponent implements Component {
|
|
|
3562
3608
|
case "strip": {
|
|
3563
3609
|
const strip = this.#strip;
|
|
3564
3610
|
const index = Number(value);
|
|
3565
|
-
if (!strip || strip.kind === "
|
|
3611
|
+
if (!strip || strip.kind === "name" || !Number.isInteger(index) || !strip.chips[index]) return;
|
|
3566
3612
|
strip.index = index;
|
|
3567
3613
|
this.#activateStripChip();
|
|
3568
3614
|
return;
|
|
@@ -3573,6 +3619,9 @@ export class ModelHubComponent implements Component {
|
|
|
3573
3619
|
case "roleName":
|
|
3574
3620
|
this.#submitRoleName();
|
|
3575
3621
|
return;
|
|
3622
|
+
case "presetName":
|
|
3623
|
+
this.#submitPresetName();
|
|
3624
|
+
return;
|
|
3576
3625
|
case "cancel":
|
|
3577
3626
|
if (this.#strip) this.#closeStrip();
|
|
3578
3627
|
return;
|
|
@@ -3622,16 +3671,17 @@ export class ModelHubComponent implements Component {
|
|
|
3622
3671
|
#describeStrip(): NativeNode | undefined {
|
|
3623
3672
|
const strip = this.#strip;
|
|
3624
3673
|
if (!strip) return undefined;
|
|
3625
|
-
if (strip.kind === "
|
|
3674
|
+
if (strip.kind === "name") {
|
|
3675
|
+
const preset = strip.purpose === "preset";
|
|
3626
3676
|
return node(
|
|
3627
3677
|
"row",
|
|
3628
3678
|
{ gap: "sm", align: "center" },
|
|
3629
3679
|
[
|
|
3630
|
-
text([span("New role name:", "accent")]),
|
|
3680
|
+
text([span(preset ? "Preset name:" : "New role name:", "accent")]),
|
|
3631
3681
|
col([strip.input], { grow: 1 }),
|
|
3632
3682
|
text([span("(letters, digits, - and _)", "dim")]),
|
|
3633
3683
|
],
|
|
3634
|
-
"roleName",
|
|
3684
|
+
preset ? "presetName" : "roleName",
|
|
3635
3685
|
);
|
|
3636
3686
|
}
|
|
3637
3687
|
let prefix: TspSpan[];
|
|
@@ -3674,8 +3724,10 @@ export class ModelHubComponent implements Component {
|
|
|
3674
3724
|
const strip = this.#strip;
|
|
3675
3725
|
if (strip) {
|
|
3676
3726
|
switch (strip.kind) {
|
|
3677
|
-
case "
|
|
3678
|
-
return
|
|
3727
|
+
case "name":
|
|
3728
|
+
return strip.purpose === "preset"
|
|
3729
|
+
? [keys("save preset", "enter"), cancel("cancel")]
|
|
3730
|
+
: [keys("create + pick model", "enter"), cancel("cancel")];
|
|
3679
3731
|
case "role":
|
|
3680
3732
|
return [keys("choose", "left", "right"), keys("assign/clear", "enter"), cancel("cancel")];
|
|
3681
3733
|
case "scope":
|
|
@@ -3741,6 +3793,7 @@ export class ModelHubComponent implements Component {
|
|
|
3741
3793
|
keys("cycle", "c"),
|
|
3742
3794
|
reorder,
|
|
3743
3795
|
keys("new", "n"),
|
|
3796
|
+
this.#callbacks.onSavePreset ? keys("save preset", "s") : undefined,
|
|
3744
3797
|
];
|
|
3745
3798
|
}
|
|
3746
3799
|
if (entry.kind === "provider" && entry.locked) {
|