gentle-pi 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-shell.md +52 -15
- package/docs/readme-reference.md +67 -7
- package/docs/review-integration.md +22 -17
- package/extensions/ask-user-question.ts +210 -0
- package/extensions/gentle-agents.ts +93 -18
- package/extensions/gentle-ai.ts +180 -37
- package/extensions/gentle-shell.ts +476 -39
- package/extensions/gentle-todo.ts +19 -1
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +2 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +297 -0
- package/lib/native-review-cli.ts +50 -10
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +256 -171
- package/lib/review-integration-v2.ts +114 -27
- package/lib/shell-bar.ts +163 -75
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +118 -16
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +103 -12
- package/lib/shell-usage.ts +120 -6
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +49 -9
- package/runtime/review-integration-v2.mjs +114 -27
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/verify-package-files.mjs +3 -4
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-question.test.ts +435 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +10 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +599 -7
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +125 -9
- package/tests/gentle-shell.test.ts +1149 -24
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +460 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +34 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +297 -299
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +146 -1
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +129 -26
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +200 -124
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +301 -8
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +120 -1
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/lib/opaque-pi-reviewer-adapter.ts +0 -404
- package/skills/release/SKILL.md +0 -137
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
|
@@ -199,6 +199,22 @@ const CAPABILITIES_SCHEMA_IDENTITIES: Readonly<Record<string, { protocolMinor: n
|
|
|
199
199
|
requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
|
|
200
200
|
optionalFeatureFloor: 14,
|
|
201
201
|
}),
|
|
202
|
+
// Ground-truthed against the published v3.4.0 binary: capabilities/v2.6
|
|
203
|
+
// advertises the same required surface as v2.5 (status/v6 is still
|
|
204
|
+
// advertised for compatibility) plus the status/v7 and status/v8 schemas,
|
|
205
|
+
// which are superset-checked additions, not requirements --
|
|
206
|
+
// decodeReviewStatusV3 already accepts v7/v8/v9 as additive extensions of
|
|
207
|
+
// v6, so the required-schema floor stays unchanged. `review assess` also
|
|
208
|
+
// gained review_due/review_due_reason/next_transition, which is unrelated
|
|
209
|
+
// to this negotiated capabilities surface. The v3.4.0 binary advertised
|
|
210
|
+
// 15 optional features, same as v2.5's binary (floor stays at 14, its
|
|
211
|
+
// established minimum).
|
|
212
|
+
"gentle-ai.review-integration.capabilities/v2.6": Object.freeze({
|
|
213
|
+
protocolMinor: 6,
|
|
214
|
+
requiredSchemas: Object.freeze([...REQUIRED_SCHEMAS_COMMON_V23, "gentle-ai.review-integration.capabilities/v2.6", "gentle-ai.review-integration.consent/v3", "gentle-ai.review-integration.start/v4", "gentle-ai.review-integration.status/v6", "gentle-ai.review-intended-untracked-selection/v1"]),
|
|
215
|
+
requiredMandatoryFeatures: REQUIRED_MANDATORY_FEATURES_V23,
|
|
216
|
+
optionalFeatureFloor: 14,
|
|
217
|
+
}),
|
|
202
218
|
});
|
|
203
219
|
const OPTIONAL_FEATURE_NAMES = Object.freeze([
|
|
204
220
|
"base_ref_workspace_overlay",
|
|
@@ -294,6 +310,7 @@ export interface ChangedPathEntry {
|
|
|
294
310
|
readonly typeChanged: boolean;
|
|
295
311
|
readonly modeOnly: boolean;
|
|
296
312
|
readonly intendedUntracked: boolean;
|
|
313
|
+
readonly generated?: true;
|
|
297
314
|
}
|
|
298
315
|
|
|
299
316
|
export interface ReviewArtifactSubjectV2 {
|
|
@@ -438,11 +455,21 @@ export interface ReviewCaptureSubmissionV1 {
|
|
|
438
455
|
values: readonly ReviewCaptureSubmissionValueV1[];
|
|
439
456
|
}
|
|
440
457
|
|
|
441
|
-
// The two
|
|
442
|
-
//
|
|
443
|
-
// self-contained authority-advancing
|
|
444
|
-
// `--agent=pi --execute=true`, with NO submission descriptor
|
|
445
|
-
//
|
|
458
|
+
// The two non-lens provider role capture operations (gentle-pi#311 P4-roles;
|
|
459
|
+
// provider side gentle-ai#3264). An older gentle-ai still renders each one as
|
|
460
|
+
// a self-contained authority-advancing vector: binding tokens plus
|
|
461
|
+
// `--agent=pi --execute=true`, with NO submission descriptor — executing the
|
|
462
|
+
// exact rendered tokens makes Go materialize the role prompt, run its own
|
|
463
|
+
// locked-down pi subprocess, and admit the verdict itself.
|
|
464
|
+
//
|
|
465
|
+
// gentle-ai's v9 contract makes both roles host-mediated exactly like a lens
|
|
466
|
+
// materialize slot instead (gentle-pi#311 P3): the collect input carries
|
|
467
|
+
// `--materialize=true` (never alongside `--execute`) plus a provider-owned
|
|
468
|
+
// `submission` descriptor, and the host completes the frozen prompt
|
|
469
|
+
// in-process and submits the result through that exact descriptor. The two
|
|
470
|
+
// wire forms are mutually exclusive on one input; see the gating in
|
|
471
|
+
// decodeCollectInput below. The known operation set is closed — an unknown
|
|
472
|
+
// role capture operation is never executed either way.
|
|
446
473
|
export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION = {
|
|
447
474
|
CAPTURE_REFUTER: "review.capture-refuter",
|
|
448
475
|
CAPTURE_VALIDATION: "review.capture-validation",
|
|
@@ -1124,7 +1151,8 @@ export function decodeReviewArtifactSubjectV2(value: unknown): ReviewArtifactSub
|
|
|
1124
1151
|
}
|
|
1125
1152
|
|
|
1126
1153
|
function decodeChangedPathEntry(value: unknown, label: string): ChangedPathEntry {
|
|
1127
|
-
const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"]);
|
|
1154
|
+
const body = exactRecord(value, label, ["path", "status", "old_mode", "new_mode", "deleted", "type_changed", "mode_only", "intended_untracked"], ["generated"]);
|
|
1155
|
+
if (body.generated !== undefined && body.generated !== true) throw new TypeError(`${label}.generated must be true when present`);
|
|
1128
1156
|
return {
|
|
1129
1157
|
path: nonempty(body.path, `${label}.path`),
|
|
1130
1158
|
status: enumeration(body.status, ["A", "D", "M", "T"] as const, `${label}.status`),
|
|
@@ -1134,6 +1162,7 @@ function decodeChangedPathEntry(value: unknown, label: string): ChangedPathEntry
|
|
|
1134
1162
|
typeChanged: boolean(body.type_changed, `${label}.type_changed`),
|
|
1135
1163
|
modeOnly: boolean(body.mode_only, `${label}.mode_only`),
|
|
1136
1164
|
intendedUntracked: boolean(body.intended_untracked, `${label}.intended_untracked`),
|
|
1165
|
+
...(body.generated === undefined ? {} : { generated: true }),
|
|
1137
1166
|
};
|
|
1138
1167
|
}
|
|
1139
1168
|
|
|
@@ -1451,11 +1480,18 @@ function decodeCaptureSubmission(value: unknown, label: string, v5: boolean, v6:
|
|
|
1451
1480
|
// one-entry values array the host relay already consumes. A payload
|
|
1452
1481
|
// carrying both wire forms at once matches no captured shape and falls
|
|
1453
1482
|
// through to the legacy decoder, which rejects the unknown `value` key.
|
|
1483
|
+
//
|
|
1484
|
+
// gentle-ai's v9 contract renders the same singular-value shape for the
|
|
1485
|
+
// two host-mediated provider role operations (capture-refuter,
|
|
1486
|
+
// capture-validation): they carry a `schema` key exactly like
|
|
1487
|
+
// capture-result, since both bind one artifact-path-or-stdin value, never
|
|
1488
|
+
// the correction-plan's numeric bounds (gentle-pi#311 P3).
|
|
1454
1489
|
if (v5 && typeof value === "object" && value !== null && "value" in value && !("values" in value)) {
|
|
1455
1490
|
const submission = exactRecord(value, label, ["operation_token", "argument_tokens", "value"]);
|
|
1456
|
-
const operationToken = enumeration(submission.operation_token, ["capture-result", "capture-correction-plan"] as const, `${label}.operation_token`);
|
|
1491
|
+
const operationToken = enumeration(submission.operation_token, ["capture-result", "capture-correction-plan", "capture-refuter", "capture-validation"] as const, `${label}.operation_token`);
|
|
1457
1492
|
const argumentTokens = stringArray(submission.argument_tokens, `${label}.argument_tokens`, { minimum: 1 });
|
|
1458
|
-
const
|
|
1493
|
+
const bindsArtifactSchema = operationToken === "capture-result" || operationToken === "capture-refuter" || operationToken === "capture-validation";
|
|
1494
|
+
const row = bindsArtifactSchema
|
|
1459
1495
|
? exactRecord(submission.value, `${label}.value`, ["slot", "domain", "schema", "substitution_location"])
|
|
1460
1496
|
: exactRecord(submission.value, `${label}.value`, ["slot", "domain", "minimum", "maximum", "substitution_location"]);
|
|
1461
1497
|
return {
|
|
@@ -1464,7 +1500,11 @@ function decodeCaptureSubmission(value: unknown, label: string, v5: boolean, v6:
|
|
|
1464
1500
|
values: [{
|
|
1465
1501
|
slot: operationToken === "capture-result"
|
|
1466
1502
|
? enumeration(row.slot, ["reviewer_result"] as const, `${label}.value.slot`)
|
|
1467
|
-
:
|
|
1503
|
+
: operationToken === "capture-correction-plan"
|
|
1504
|
+
? enumeration(row.slot, ["correction_lines"] as const, `${label}.value.slot`)
|
|
1505
|
+
: operationToken === "capture-refuter"
|
|
1506
|
+
? enumeration(row.slot, ["provider_refuter"] as const, `${label}.value.slot`)
|
|
1507
|
+
: enumeration(row.slot, ["provider_targeted_validator"] as const, `${label}.value.slot`),
|
|
1468
1508
|
domain: nonempty(row.domain, `${label}.value.domain`),
|
|
1469
1509
|
...(row.schema === undefined ? {} : { schema: nonempty(row.schema, `${label}.value.schema`) }),
|
|
1470
1510
|
...(row.minimum === undefined ? {} : { minimum: integer(row.minimum, `${label}.value.minimum`, 1, 200) }),
|
|
@@ -1605,7 +1645,7 @@ const CORRECTION_REQUEST_REASON_CODES = Object.freeze(["correction_plan_required
|
|
|
1605
1645
|
// v5 capture operations that must carry a submission descriptor.
|
|
1606
1646
|
const V5_SUBMISSION_CAPTURE_OPERATIONS = Object.freeze(["review.capture-correction-plan"] as const);
|
|
1607
1647
|
|
|
1608
|
-
function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: boolean): ReviewCollectInputV3 {
|
|
1648
|
+
function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: boolean, v9: boolean): ReviewCollectInputV3 {
|
|
1609
1649
|
const input = exactRecord(value, label, ["name", "schema", "capture_operation", "arguments"], ["artifact_subject", "base_tree", "candidate_tree", "changed_path_manifest", "submission", ...(v5 ? ["provider_task", "validation_request"] : [])]);
|
|
1610
1650
|
const name = text(input.name, `${label}.name`, { minimum: 1, pattern: /^[a-z0-9_]+$/ });
|
|
1611
1651
|
const schema = nonempty(input.schema, `${label}.schema`);
|
|
@@ -1656,13 +1696,18 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1656
1696
|
sha256(argumentsList[5]!.value, `${label}.arguments[5].value`);
|
|
1657
1697
|
}
|
|
1658
1698
|
|
|
1659
|
-
// gentle-pi#311 P4-roles: the two
|
|
1660
|
-
// operations render
|
|
1699
|
+
// gentle-pi#311 P4-roles / P3: the two non-lens provider role capture
|
|
1700
|
+
// operations render either wire form on one input, never both at once.
|
|
1701
|
+
// An older gentle-ai renders a SELF-CONTAINED authority-advancing vector
|
|
1702
|
+
// (binding tokens + --agent=pi --execute=true, no submission): executing
|
|
1661
1703
|
// the exact rendered tokens makes Go materialize the role prompt, run its
|
|
1662
|
-
// own locked-down pi subprocess, and admit the raw verdict
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1704
|
+
// own locked-down pi subprocess, and admit the raw verdict. gentle-ai's
|
|
1705
|
+
// v9 contract instead renders a HOST-MEDIATED form: the same binding
|
|
1706
|
+
// tokens plus --materialize=true (never --execute) and a provider-owned
|
|
1707
|
+
// submission descriptor, exactly like a lens capture-result materialize
|
|
1708
|
+
// slot — the host completes the frozen prompt in-process and submits
|
|
1709
|
+
// through that descriptor.
|
|
1710
|
+
const isRoleCaptureOperation = (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(captureOperation);
|
|
1666
1711
|
if (captureOperation === REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_REFUTER && schema !== "https://gentle-ai.dev/schema/review/refuter/v1") {
|
|
1667
1712
|
throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/refuter/v1`);
|
|
1668
1713
|
}
|
|
@@ -1693,8 +1738,30 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1693
1738
|
if (providerArgument("target") !== validationRequest.correctionTargetIdentity) throw new TypeError(`${label}.arguments target must bind validation_request.correction_target_identity`);
|
|
1694
1739
|
if (providerArgument("request-hash") !== validationRequest.requestHash) throw new TypeError(`${label}.arguments request-hash must bind validation_request.request_hash`);
|
|
1695
1740
|
}
|
|
1696
|
-
if (input.submission !== undefined
|
|
1697
|
-
|
|
1741
|
+
if (isRoleCaptureOperation && input.submission !== undefined) {
|
|
1742
|
+
// A submission descriptor only ever rides the v9 host-mediated form
|
|
1743
|
+
// (gentle-ai's v9 provider contract; the sibling branch ahead of the
|
|
1744
|
+
// currently-released v8). A v8-or-earlier provider (status/v3..v8) never
|
|
1745
|
+
// renders one, even if it happened to carry a submission-shaped payload,
|
|
1746
|
+
// so this is gated on v9 specifically — not on v5, which v8 already
|
|
1747
|
+
// satisfies. Within a v9 payload the discriminator is exactly the
|
|
1748
|
+
// materialize slot's --materialize=true with no --execute, the same one
|
|
1749
|
+
// a lens capture-result materialize slot already uses. Mixing a
|
|
1750
|
+
// submission with the old self-contained --execute=true vector, or with
|
|
1751
|
+
// neither flag, is a malformed vector.
|
|
1752
|
+
if (!v9) {
|
|
1753
|
+
throw new TypeError(`${label}.submission requires the v9 provider contract for the host-mediated ${captureOperation} form`);
|
|
1754
|
+
}
|
|
1755
|
+
const argumentNamed = (argumentName: string): string | undefined => {
|
|
1756
|
+
const matches = argumentsList.filter((argument) => argument.name === argumentName);
|
|
1757
|
+
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
1758
|
+
};
|
|
1759
|
+
if (argumentNamed("materialize") !== "true") {
|
|
1760
|
+
throw new TypeError(`${label}.submission requires --materialize=true on the host-mediated ${captureOperation} form`);
|
|
1761
|
+
}
|
|
1762
|
+
if (argumentNamed("execute") !== undefined) {
|
|
1763
|
+
throw new TypeError(`${label} must not carry --execute alongside a submission descriptor on ${captureOperation}`);
|
|
1764
|
+
}
|
|
1698
1765
|
}
|
|
1699
1766
|
|
|
1700
1767
|
if (captureOperation === "review.capture-result") {
|
|
@@ -1711,7 +1778,12 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1711
1778
|
const submissionOperations: readonly string[] = v5
|
|
1712
1779
|
? ["review.capture-result", "review.capture-correction-plan", ...(v6 ? ["external.select_intended_untracked"] : [])]
|
|
1713
1780
|
: ["review.capture-result"];
|
|
1714
|
-
|
|
1781
|
+
// Role operations are gated above by the materialize/execute discriminator
|
|
1782
|
+
// instead of this allowlist: an older gentle-ai's self-contained vector
|
|
1783
|
+
// legitimately carries no submission regardless of v5/v6, so they cannot
|
|
1784
|
+
// simply join the list the way every other submission-carrying operation
|
|
1785
|
+
// does.
|
|
1786
|
+
if (input.submission !== undefined && !isRoleCaptureOperation && !submissionOperations.includes(captureOperation)) {
|
|
1715
1787
|
throw new TypeError(v5 ? `${label}.submission is only valid for ${submissionOperations.join(", ")}` : `${label}.submission is only valid for review.capture-result`);
|
|
1716
1788
|
}
|
|
1717
1789
|
if (intendedUntracked && input.submission === undefined) throw new TypeError(`${label}.submission is required for external.select_intended_untracked`);
|
|
@@ -1749,8 +1821,14 @@ export function decodeReviewManagedAssetsContinuationV1(value: unknown, label: s
|
|
|
1749
1821
|
return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
|
|
1750
1822
|
}
|
|
1751
1823
|
|
|
1752
|
-
export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean } = {}): ReviewNextTransitionV3 {
|
|
1753
|
-
|
|
1824
|
+
export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean; v9?: boolean } = {}): ReviewNextTransitionV3 {
|
|
1825
|
+
// v9 (gentle-ai's next contract, ahead of the currently-released v8) is
|
|
1826
|
+
// the only rung this sub-decoder distinguishes beyond v6: v7 and v8 add
|
|
1827
|
+
// nothing to next_transition/collect-input decoding (v8 only extended the
|
|
1828
|
+
// reviewer-result transition for OpenCode provider tasks at the top
|
|
1829
|
+
// status level), so they decode on the exact v6 surface here.
|
|
1830
|
+
const v9 = options.v9 === true;
|
|
1831
|
+
const v6 = options.v6 === true || v9;
|
|
1754
1832
|
const v5 = options.v5 === true || v6;
|
|
1755
1833
|
const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation", "unachievable_lens_slots"]);
|
|
1756
1834
|
const kind = enumeration(transition.kind, ["execute", "collect", "stop"] as const, "next_transition.kind");
|
|
@@ -1812,7 +1890,7 @@ export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boo
|
|
|
1812
1890
|
}
|
|
1813
1891
|
if (kind === "collect") {
|
|
1814
1892
|
const collect = exactRecord(transition.collect, "next_transition.collect", ["inputs"]);
|
|
1815
|
-
const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5, v6), { minimum: 1 });
|
|
1893
|
+
const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5, v6, v9), { minimum: 1 });
|
|
1816
1894
|
if (transition.execute !== undefined) throw new TypeError("next_transition.execute is incompatible with collect");
|
|
1817
1895
|
return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
|
|
1818
1896
|
}
|
|
@@ -1925,16 +2003,25 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1925
2003
|
// surfaces. status/v6 adds the intended-untracked selection; status/v7
|
|
1926
2004
|
// (gentle-ai v2.6.0, advertised through capabilities/v2.5 alongside v6)
|
|
1927
2005
|
// adds optional `eligible_untracked_inventory` and escalation metadata,
|
|
1928
|
-
// so it is decoded on the v6 surface.
|
|
1929
|
-
//
|
|
2006
|
+
// so it is decoded on the v6 surface. status/v8 (gentle-ai main, PR #4765;
|
|
2007
|
+
// the current released contract) only extended the reviewer-result
|
|
2008
|
+
// transition for OpenCode provider tasks -- no new top-level key -- so it
|
|
2009
|
+
// decodes on the exact v7 surface too. status/v9 (the sibling gentle-ai
|
|
2010
|
+
// branch's contract, ahead of the released v8) adds nothing at this top
|
|
2011
|
+
// level either: its one addition is the host-mediated role `submission`
|
|
2012
|
+
// on a next_transition.collect input (gentle-pi#311 P3), gated on v9 in
|
|
2013
|
+
// decodeCollectInput, not here. v3 keeps rejecting every
|
|
2014
|
+
// v5/v6/v7/v8/v9-only field.
|
|
1930
2015
|
const schema = typeof value === "object" && value !== null ? (value as Record<string, unknown>).schema : undefined;
|
|
1931
|
-
const
|
|
2016
|
+
const v9 = schema === "gentle-ai.review-integration.status/v9";
|
|
2017
|
+
const v8 = v9 || schema === "gentle-ai.review-integration.status/v8";
|
|
2018
|
+
const v7 = v8 || schema === "gentle-ai.review-integration.status/v7";
|
|
1932
2019
|
const v6 = v7 || schema === "gentle-ai.review-integration.status/v6";
|
|
1933
2020
|
const v5 = v6 || schema === "gentle-ai.review-integration.status/v5";
|
|
1934
2021
|
const body = exactRecord(value, "status", [
|
|
1935
2022
|
"schema", "contract", "operation", "applicability", "action", "replayability", "target_identity", "projection", "repair", "candidates",
|
|
1936
2023
|
], ["authority", "frozen", "action_disposition", "eligibility", "next_transition", "authority_target_identity", ...(v5 ? ["receipt", "forecast", "repository_context", "validation_request"] : []), ...(v7 ? ["eligible_untracked_inventory", "escalation"] : [])]);
|
|
1937
|
-
requireIdentity(body, v7 ? "gentle-ai.review-integration.status/v7" : v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
|
|
2024
|
+
requireIdentity(body, v9 ? "gentle-ai.review-integration.status/v9" : v8 ? "gentle-ai.review-integration.status/v8" : v7 ? "gentle-ai.review-integration.status/v7" : v6 ? "gentle-ai.review-integration.status/v6" : v5 ? "gentle-ai.review-integration.status/v5" : "gentle-ai.review-integration.status/v3", REVIEW_INTEGRATION_OPERATION.STATUS);
|
|
1938
2025
|
|
|
1939
2026
|
const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] as const, "status.applicability");
|
|
1940
2027
|
let receipt: ReviewStatusReceiptV1 | undefined;
|
|
@@ -1982,7 +2069,7 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1982
2069
|
if (action === "recover" && actionDisposition === undefined) throw new TypeError("recover status requires action_disposition");
|
|
1983
2070
|
if (action !== "recover" && actionDisposition !== undefined) throw new TypeError("status.action_disposition is only valid for the recover action");
|
|
1984
2071
|
if (body.eligibility !== undefined) decodeEligibility(body.eligibility, "status.eligibility");
|
|
1985
|
-
const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6 });
|
|
2072
|
+
const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6, v9 });
|
|
1986
2073
|
const validationRequest = v5 && body.validation_request !== undefined
|
|
1987
2074
|
? decodeReviewTargetedValidationRequestV1(body.validation_request, "status.validation_request")
|
|
1988
2075
|
: undefined;
|
package/lib/shell-bar.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
2
2
|
import { GAUGE_CELLS, gaugeTone, paintGauge, renderGauge, type GaugeTone } from "./shell-gauge.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { renderUsageBar, selectUsageLimit, type ProviderUsage, type UsageWindow } from "./shell-usage.ts";
|
|
4
4
|
import { sanitizeTerminalText } from "./terminal-theme.ts";
|
|
5
5
|
import { CARD_TONE, cardInnerWidth, renderCard } from "./shell-card.ts";
|
|
6
6
|
|
|
@@ -27,6 +27,43 @@ export interface ShellBarModel {
|
|
|
27
27
|
statuses: string[];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// The live header row above the fullscreen rail: session identity plus the
|
|
31
|
+
// two counters that tick every frame (context, cost). Deliberately narrower
|
|
32
|
+
// than ShellBarModel — extension statuses and the working/thinking state
|
|
33
|
+
// never reach the header, so there is nothing on this type for them to leak
|
|
34
|
+
// through.
|
|
35
|
+
export interface ShellHeaderModel {
|
|
36
|
+
cwd: string;
|
|
37
|
+
branch: string | null;
|
|
38
|
+
dirty: number | undefined;
|
|
39
|
+
modelId: string;
|
|
40
|
+
effort: string | undefined;
|
|
41
|
+
profile?: string;
|
|
42
|
+
contextPercent: number | null;
|
|
43
|
+
costTotal: number;
|
|
44
|
+
subscription: boolean;
|
|
45
|
+
// The active provider's subscription usage, shown as its own segment after
|
|
46
|
+
// cost. Unlike the sidebar's old per-model usage table, this is one
|
|
47
|
+
// compact line — the same windows the compact bar already meters.
|
|
48
|
+
usage: ProviderUsage | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildShellHeaderModel(model: ShellBarModel): ShellHeaderModel {
|
|
52
|
+
const { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage } = model;
|
|
53
|
+
return { cwd, branch, dirty, modelId, effort, profile, contextPercent, costTotal, subscription, usage };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A column span (`[start, end)`, in the rendered line's visible columns) a click must land in to hit the usage segment. */
|
|
57
|
+
export interface HeaderUsageSpan {
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ShellHeaderResult {
|
|
63
|
+
text: string;
|
|
64
|
+
usageSpan?: HeaderUsageSpan;
|
|
65
|
+
}
|
|
66
|
+
|
|
30
67
|
export interface ShellBarTheme {
|
|
31
68
|
fg(color: string, text: string): string;
|
|
32
69
|
bold(text: string): string;
|
|
@@ -53,12 +90,6 @@ export const SHELL_BAR_SEPARATOR = "⟡";
|
|
|
53
90
|
export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
|
|
54
91
|
const RIGHT_PADDING = 2;
|
|
55
92
|
const COMPACT_BRANCH_WIDTH = 15;
|
|
56
|
-
// The rows the sidebar prints for a provider with per-model allowances use the
|
|
57
|
-
// bar's shorter meter: the rail is 50 columns wide, and the panel's 16 cells
|
|
58
|
-
// would leave no room for the model ids.
|
|
59
|
-
const SIDEBAR_USAGE_METER_CELLS = GAUGE_CELLS;
|
|
60
|
-
// Meter, its two spaces and the right-aligned percentage.
|
|
61
|
-
const SIDEBAR_USAGE_ROW_FIXED = SIDEBAR_USAGE_METER_CELLS + 6;
|
|
62
93
|
|
|
63
94
|
export function shellEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
64
95
|
if (env.GENTLE_PI_AGENTS_CHILD === "1") return false;
|
|
@@ -85,17 +116,36 @@ function sanitizeStatus(text: string): string {
|
|
|
85
116
|
return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim();
|
|
86
117
|
}
|
|
87
118
|
|
|
88
|
-
|
|
119
|
+
// Shared by the compact bar, the sidebar Status card, and the fullscreen
|
|
120
|
+
// header row, so the three surfaces never drift on how they paint the same
|
|
121
|
+
// facts.
|
|
122
|
+
function locationSegment(model: Pick<ShellBarModel, "cwd" | "branch" | "dirty">, theme: ShellBarTheme): string {
|
|
89
123
|
const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : "";
|
|
90
|
-
|
|
124
|
+
return model.branch
|
|
91
125
|
? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}`
|
|
92
126
|
: theme.fg(ROLE.PATH, model.cwd) + dirty;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function executionSegment(modelId: string, effort: string | undefined, theme: ShellBarTheme): string {
|
|
130
|
+
return effort
|
|
131
|
+
? `${theme.fg(ROLE.MODEL, modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, effort)}`
|
|
132
|
+
: theme.fg(ROLE.MODEL, modelId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function contextSegment(contextPercent: number | null, theme: ShellBarTheme): string {
|
|
136
|
+
const percentText = contextPercent === null ? "?%" : `${Math.round(contextPercent)}%`;
|
|
137
|
+
return `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function costSegment(costTotal: number, subscription: boolean, theme: ShellBarTheme): string {
|
|
141
|
+
return theme.fg(ROLE.VALUE, formatCost(costTotal, subscription));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
|
|
145
|
+
const location = locationSegment(model, theme);
|
|
146
|
+
const modelSegment = executionSegment(model.modelId, model.effort, theme);
|
|
147
|
+
const context = contextSegment(model.contextPercent, theme);
|
|
148
|
+
const cost = costSegment(model.costTotal, model.subscription, theme);
|
|
99
149
|
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
|
|
100
150
|
const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
|
|
101
151
|
return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
|
|
@@ -125,52 +175,6 @@ function joinSegments(segments: string[], theme: ShellBarTheme): string {
|
|
|
125
175
|
return segments.join(` ${theme.fg(ROLE.SEPARATOR, SHELL_BAR_SEPARATOR)} `);
|
|
126
176
|
}
|
|
127
177
|
|
|
128
|
-
// A row exists to show what is being consumed, so a window that consumed
|
|
129
|
-
// nothing is noise the sidebar drops. The threshold is the row's own number and
|
|
130
|
-
// nothing else: the render path prints `Math.round(percent)`, so a fraction
|
|
131
|
-
// below half a percent prints `0%` and disappears while half a percent keeps its
|
|
132
|
-
// row and prints `1%` — no second scale and no separate epsilon. A window whose
|
|
133
|
-
// percent is not a number never equals zero, so it keeps its row instead of
|
|
134
|
-
// being dropped in silence. The bar and the panel keep their own contract and
|
|
135
|
-
// still print a zero allowance.
|
|
136
|
-
function consumedNothing(usedPercent: number): boolean {
|
|
137
|
-
return Math.round(usedPercent) === 0;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// The sidebar is the surface that never needs opening, so a provider with
|
|
141
|
-
// per-model allowances prints the panel's model rows there too — the most
|
|
142
|
-
// consumed family first, its models inside it — and leaves the aggregate totals
|
|
143
|
-
// and the reset dates to the bar and the panel. Providers without raw
|
|
144
|
-
// allowances keep the one aggregate line the bar has always drawn for the model
|
|
145
|
-
// in use.
|
|
146
|
-
function sidebarUsageLines(usage: ProviderUsage, modelId: string, theme: ShellBarTheme, available: number): string[] {
|
|
147
|
-
if (!allowanceGroupsSupported(usage.limits)) {
|
|
148
|
-
// The aggregate line is one row, so its windows decide together: one
|
|
149
|
-
// consumed window keeps the sharing row, all of them zero drop it. A limit
|
|
150
|
-
// with no windows is not "zero consumption" — there is nothing to draw, and
|
|
151
|
-
// renderUsageBar already answers that — so the rule only speaks when there
|
|
152
|
-
// is a window to judge.
|
|
153
|
-
const windows = selectUsageLimit(usage, modelId)?.windows ?? [];
|
|
154
|
-
if (windows.length > 0 && windows.every((window) => consumedNothing(window.usedPercent))) return [];
|
|
155
|
-
const line = renderUsageBar(usage, theme, modelId);
|
|
156
|
-
return line ? [line] : [];
|
|
157
|
-
}
|
|
158
|
-
const rows = groupUsageLimits(usage.limits)
|
|
159
|
-
.flatMap((limit) =>
|
|
160
|
-
limit.windows.map((window) => ({ name: [limit.name, window.label].filter((part) => part.length > 0).join(" "), window })),
|
|
161
|
-
)
|
|
162
|
-
.filter((row) => !consumedNothing(row.window.usedPercent));
|
|
163
|
-
// The name column gives way first: it is the only part that can be clipped
|
|
164
|
-
// without losing the number the row exists to show.
|
|
165
|
-
const widest = rows.reduce((width, row) => Math.max(width, row.name.length), 0);
|
|
166
|
-
const nameWidth = Math.min(widest, Math.max(1, available - SIDEBAR_USAGE_ROW_FIXED));
|
|
167
|
-
return rows.map((row) => {
|
|
168
|
-
const name = row.name.length > nameWidth ? clipText(row.name, nameWidth) : row.name.padEnd(nameWidth);
|
|
169
|
-
const percent = `${Math.round(row.window.usedPercent)}%`.padStart(4);
|
|
170
|
-
return `${theme.fg(ROLE.LABEL, name)} ${paintGauge(row.window.usedPercent, theme, SIDEBAR_USAGE_METER_CELLS)} ${theme.fg(ROLE.VALUE, percent)}`;
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
178
|
// Sidebar groups use structured fields, never positional compact-bar segments
|
|
175
179
|
// or inferred meanings from opaque extension status strings.
|
|
176
180
|
export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
|
|
@@ -178,13 +182,14 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
178
182
|
const label = (text: string) => theme.fg(ROLE.LABEL, text);
|
|
179
183
|
const changes = model.changes;
|
|
180
184
|
const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
|
|
181
|
-
const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
|
|
182
|
-
const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
|
|
183
185
|
// Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
|
|
184
186
|
// the same inset without consuming the card's right border.
|
|
185
187
|
const innerWidth = cardInnerWidth(width);
|
|
186
188
|
const inset = Math.min(1, innerWidth - 1);
|
|
187
|
-
|
|
189
|
+
// Model, effort, context, cost, and the per-model usage table now live in
|
|
190
|
+
// the always-visible header row (and /gentle:usage for the full table);
|
|
191
|
+
// this event-driven card keeps only what a footer/model-switch event does
|
|
192
|
+
// not already refresh every frame.
|
|
188
193
|
const groups: Array<{ title: string; lines: string[] }> = [
|
|
189
194
|
{
|
|
190
195
|
title: "Project",
|
|
@@ -192,8 +197,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
192
197
|
value(model.cwd),
|
|
193
198
|
...(branch ? [branch] : []),
|
|
194
199
|
...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []),
|
|
195
|
-
`${label("Model")} ${value(model.modelId)}`,
|
|
196
|
-
...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []),
|
|
197
200
|
...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
|
|
198
201
|
],
|
|
199
202
|
},
|
|
@@ -207,15 +210,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
207
210
|
label("/gentle:changes"),
|
|
208
211
|
],
|
|
209
212
|
},
|
|
210
|
-
{
|
|
211
|
-
title: "Usage",
|
|
212
|
-
lines: [
|
|
213
|
-
`${label("Context")} ${paintGauge(model.contextPercent, theme)} ${value(percent)}`,
|
|
214
|
-
capacity,
|
|
215
|
-
`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`,
|
|
216
|
-
...usageLines,
|
|
217
|
-
],
|
|
218
|
-
},
|
|
219
213
|
{ title: "Integrations", lines: model.statuses.length
|
|
220
214
|
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
|
|
221
215
|
: [label("No status reported")] },
|
|
@@ -230,6 +224,100 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
230
224
|
return renderCard({ title: "Status", body, tone: CARD_TONE.INFO }, theme, width, { expanded: true });
|
|
231
225
|
}
|
|
232
226
|
|
|
227
|
+
const HEADER_BRAND = "✿ Gentle Shell";
|
|
228
|
+
|
|
229
|
+
// Narrower than the width, widest first: dropping the profile, then the
|
|
230
|
+
// effort, then the whole location keeps the brand and the bare model id
|
|
231
|
+
// alive as long as anything can still share the row with the right-aligned
|
|
232
|
+
// counters.
|
|
233
|
+
function headerLeftStages(model: ShellHeaderModel, theme: ShellBarTheme): string[][] {
|
|
234
|
+
const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND));
|
|
235
|
+
const location = locationSegment(model, theme);
|
|
236
|
+
const withEffort = executionSegment(model.modelId, model.effort, theme);
|
|
237
|
+
const modelOnly = executionSegment(model.modelId, undefined, theme);
|
|
238
|
+
const withProfile = model.profile ? `${withEffort} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.MODEL, sanitizeStatus(model.profile))}` : withEffort;
|
|
239
|
+
return [
|
|
240
|
+
[brand, location, withProfile],
|
|
241
|
+
[brand, location, withEffort],
|
|
242
|
+
[brand, location, modelOnly],
|
|
243
|
+
[brand, modelOnly],
|
|
244
|
+
[brand],
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const USAGE_LABEL_ROLE = ROLE.LABEL;
|
|
249
|
+
const USAGE_HINT_ROLE = "dim";
|
|
250
|
+
|
|
251
|
+
function usageWindowText(window: UsageWindow, theme: ShellBarTheme, withGauge: boolean): string {
|
|
252
|
+
const percent = `${Math.round(window.usedPercent)}%`;
|
|
253
|
+
const parts = [
|
|
254
|
+
...(window.label.length > 0 ? [theme.fg(ROLE.LABEL, window.label)] : []),
|
|
255
|
+
...(withGauge ? [paintGauge(window.usedPercent, theme)] : []),
|
|
256
|
+
theme.fg(ROLE.VALUE, percent),
|
|
257
|
+
];
|
|
258
|
+
return parts.join(" ");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Three degrading shapes for the same windows, narrowest last: every window
|
|
262
|
+
// with its gauge, every window as text only, or just the first window as
|
|
263
|
+
// text only. A provider with no usage data at all has no windows to shape,
|
|
264
|
+
// so all three collapse to the bare "usage" label plus the shortcut hint.
|
|
265
|
+
type UsageStage = "full" | "text" | "primary";
|
|
266
|
+
function usageSegmentText(windows: UsageWindow[], theme: ShellBarTheme, stage: UsageStage, hint: string | undefined): string {
|
|
267
|
+
const label = theme.fg(USAGE_LABEL_ROLE, "usage");
|
|
268
|
+
const shown = stage === "primary" ? windows.slice(0, 1) : windows;
|
|
269
|
+
const body = shown.map((window) => usageWindowText(window, theme, stage === "full")).join(` ${theme.fg(ROLE.LABEL, "·")} `);
|
|
270
|
+
const head = body.length > 0 ? `${label} ${body}` : label;
|
|
271
|
+
return hint ? `${head} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(USAGE_HINT_ROLE, hint)}` : head;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function renderShellHeaderBar(model: ShellHeaderModel, theme: ShellBarTheme, width: number, usageHint?: string): ShellHeaderResult {
|
|
275
|
+
const targetWidth = Math.max(0, Math.floor(width));
|
|
276
|
+
const ctxCost = joinSegments([contextSegment(model.contextPercent, theme), costSegment(model.costTotal, model.subscription, theme)], theme);
|
|
277
|
+
const windows = model.usage ? (selectUsageLimit(model.usage, model.modelId)?.windows ?? []) : [];
|
|
278
|
+
const leftStages = headerLeftStages(model, theme);
|
|
279
|
+
const minimalLeft = leftStages.length - 2; // brand + bare model id, before dropping the model too
|
|
280
|
+
// One flat, ordered cascade — never a per-stage nested search — so the
|
|
281
|
+
// left group fully degrades (profile → effort → location) before usage
|
|
282
|
+
// ever gives anything up, and usage fully degrades (gauges → secondary
|
|
283
|
+
// windows → the whole segment) before ctx/cost is touched: every window
|
|
284
|
+
// with its gauge, then text-only, then the first window only, then gone.
|
|
285
|
+
// Unlike the compact bar's usage meter (hidden with no data), this is a
|
|
286
|
+
// standing, clickable affordance — even with nothing to report it still
|
|
287
|
+
// reads "usage" plus the shortcut hint, unless disabled, width permitting.
|
|
288
|
+
const usageStages: Array<UsageStage | undefined> = ["full", "text", "primary", undefined];
|
|
289
|
+
const attempts: Array<{ leftIndex: number; usageStage: UsageStage | undefined }> = [
|
|
290
|
+
...leftStages.slice(0, minimalLeft).map((_, leftIndex) => ({ leftIndex, usageStage: "full" as UsageStage })),
|
|
291
|
+
...usageStages.map((usageStage) => ({ leftIndex: minimalLeft, usageStage })),
|
|
292
|
+
{ leftIndex: leftStages.length - 1, usageStage: undefined },
|
|
293
|
+
];
|
|
294
|
+
for (const { leftIndex, usageStage } of attempts) {
|
|
295
|
+
const usageText = usageStage ? usageSegmentText(windows, theme, usageStage, usageHint) : undefined;
|
|
296
|
+
const right = usageText ? joinSegments([ctxCost, usageText], theme) : ctxCost;
|
|
297
|
+
const left = joinSegments(leftStages[leftIndex]!, theme);
|
|
298
|
+
if (visibleWidth(left) + RIGHT_PADDING + visibleWidth(right) > targetWidth) continue;
|
|
299
|
+
const text = left + " ".repeat(targetWidth - visibleWidth(left) - visibleWidth(right)) + right;
|
|
300
|
+
if (!usageText) return { text };
|
|
301
|
+
const usageStart = visibleWidth(left) + (targetWidth - visibleWidth(left) - visibleWidth(right)) + visibleWidth(ctxCost) + visibleWidth(` ${SHELL_BAR_SEPARATOR} `);
|
|
302
|
+
return { text, usageSpan: { start: usageStart, end: usageStart + visibleWidth(usageText) } };
|
|
303
|
+
}
|
|
304
|
+
const brand = theme.fg(ROLE.BRAND, theme.bold(HEADER_BRAND));
|
|
305
|
+
return { text: visibleWidth(brand) <= targetWidth ? brand : "" };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The rule row painted directly under the header bar: one full-width horizontal
|
|
309
|
+
// line in the same theme role as the editor frame (PROMPT_FRAME_ROLE in
|
|
310
|
+
// extensions/gentle-shell.ts), so the status row and the prompt read as one
|
|
311
|
+
// panel. It exists only while the fullscreen sidebar is active — when the
|
|
312
|
+
// sidebar is not shown the header rail never renders and the rule goes away
|
|
313
|
+
// with it.
|
|
314
|
+
const HEADER_RULE_CHAR = "─";
|
|
315
|
+
const HEADER_RULE_ROLE = "border";
|
|
316
|
+
|
|
317
|
+
export function renderShellHeaderRule(theme: ShellBarTheme, width: number): string {
|
|
318
|
+
return theme.fg(HEADER_RULE_ROLE, HEADER_RULE_CHAR.repeat(Math.max(0, Math.floor(width))));
|
|
319
|
+
}
|
|
320
|
+
|
|
233
321
|
export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
|
|
234
322
|
let segments = buildSegments(model, theme);
|
|
235
323
|
const right = model.sessionName ? theme.fg(ROLE.SESSION, model.sessionName) : undefined;
|
package/lib/shell-card.ts
CHANGED
|
@@ -32,8 +32,18 @@ export interface CardRenderOptions {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export const CARD_GLYPH = "✿";
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Frame and title paint with the same role for every tone except INFO, whose
|
|
36
|
+
// rounded frame stays in the theme's plain border role while its title
|
|
37
|
+
// carries the accent role — the rose look every informational card (sidebar,
|
|
38
|
+
// review preflight, a quiet Agents widget, ...) uses.
|
|
39
|
+
const FRAME_ROLE: Record<CardTone, string> = {
|
|
40
|
+
[CARD_TONE.INFO]: "border",
|
|
41
|
+
[CARD_TONE.SUCCESS]: "success",
|
|
42
|
+
[CARD_TONE.WARNING]: "warning",
|
|
43
|
+
[CARD_TONE.ERROR]: "error",
|
|
44
|
+
};
|
|
45
|
+
const TITLE_ROLE: Record<CardTone, string> = {
|
|
46
|
+
[CARD_TONE.INFO]: "accent",
|
|
37
47
|
[CARD_TONE.SUCCESS]: "success",
|
|
38
48
|
[CARD_TONE.WARNING]: "warning",
|
|
39
49
|
[CARD_TONE.ERROR]: "error",
|
|
@@ -51,8 +61,8 @@ function rule(length: number): string {
|
|
|
51
61
|
function titleText(card: Card, theme: CardTheme): { styled: string; width: number } {
|
|
52
62
|
const head = `${card.glyph ?? CARD_GLYPH} ${card.title}`;
|
|
53
63
|
const styled = card.subtitle
|
|
54
|
-
? `${theme.fg(
|
|
55
|
-
: theme.fg(
|
|
64
|
+
? `${theme.fg(TITLE_ROLE[card.tone], head)} ${theme.fg(SUBTITLE_ROLE, SEPARATOR)} ${theme.fg(SUBTITLE_ROLE, card.subtitle)}`
|
|
65
|
+
: theme.fg(TITLE_ROLE[card.tone], head);
|
|
56
66
|
return { styled, width: visibleWidth(head) + (card.subtitle ? visibleWidth(card.subtitle) + 3 : 0) };
|
|
57
67
|
}
|
|
58
68
|
|
|
@@ -61,14 +71,14 @@ function bodyLines(card: Card, innerWidth: number): string[] {
|
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
function frame(theme: CardTheme, tone: CardTone, text: string): string {
|
|
64
|
-
return theme.fg(
|
|
74
|
+
return theme.fg(FRAME_ROLE[tone], text);
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
export function cardTop(card: Card, theme: CardTheme, width: number, hint?: string): string {
|
|
68
78
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
69
79
|
if (targetWidth === 0) return "";
|
|
70
80
|
if (targetWidth < 5) {
|
|
71
|
-
const left = theme.fg(
|
|
81
|
+
const left = theme.fg(FRAME_ROLE[card.tone], "╭");
|
|
72
82
|
if (targetWidth === 1) return left;
|
|
73
83
|
return left + frame(theme, card.tone, `${rule(targetWidth - 2)}╮`);
|
|
74
84
|
}
|
|
@@ -82,13 +92,13 @@ export function cardTop(card: Card, theme: CardTheme, width: number, hint?: stri
|
|
|
82
92
|
const styledTitleWidth = title.width <= titleWidth ? title.width : visibleWidth(styledTitle);
|
|
83
93
|
const fill = rule(targetWidth - styledTitleWidth - 5 - hintWidth);
|
|
84
94
|
const tail = shownHint ? ` ${theme.fg(HINT_ROLE, shownHint)} ` : "";
|
|
85
|
-
return theme.fg(
|
|
95
|
+
return theme.fg(FRAME_ROLE[card.tone], "╭") + frame(theme, card.tone, "─ ") + styledTitle + frame(theme, card.tone, ` ${fill}`) + tail + frame(theme, card.tone, "╮");
|
|
86
96
|
}
|
|
87
97
|
|
|
88
98
|
export function cardLine(text: string, tone: CardTone, theme: CardTheme, width: number): string {
|
|
89
99
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
90
100
|
if (targetWidth === 0) return "";
|
|
91
|
-
const left = theme.fg(
|
|
101
|
+
const left = theme.fg(FRAME_ROLE[tone], "│");
|
|
92
102
|
if (targetWidth === 1) return left;
|
|
93
103
|
if (targetWidth === 2) return left + frame(theme, tone, "│");
|
|
94
104
|
if (targetWidth === 3) return `${left} ${frame(theme, tone, "│")}`;
|
|
@@ -102,7 +112,7 @@ export function cardLine(text: string, tone: CardTone, theme: CardTheme, width:
|
|
|
102
112
|
export function cardBottom(tone: CardTone, theme: CardTheme, width: number): string {
|
|
103
113
|
const targetWidth = Math.max(0, Math.floor(width));
|
|
104
114
|
if (targetWidth === 0) return "";
|
|
105
|
-
const left = theme.fg(
|
|
115
|
+
const left = theme.fg(FRAME_ROLE[tone], "╰");
|
|
106
116
|
if (targetWidth === 1) return left;
|
|
107
117
|
return left + frame(theme, tone, `${rule(targetWidth - 2)}╯`);
|
|
108
118
|
}
|