gentle-pi 3.2.0 → 3.3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- 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 +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- 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/command-palette.test.ts +1 -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 +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -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 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -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 +224 -6
- 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 +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -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",
|
|
@@ -438,11 +454,21 @@ export interface ReviewCaptureSubmissionV1 {
|
|
|
438
454
|
values: readonly ReviewCaptureSubmissionValueV1[];
|
|
439
455
|
}
|
|
440
456
|
|
|
441
|
-
// The two
|
|
442
|
-
//
|
|
443
|
-
// self-contained authority-advancing
|
|
444
|
-
// `--agent=pi --execute=true`, with NO submission descriptor
|
|
445
|
-
//
|
|
457
|
+
// The two non-lens provider role capture operations (gentle-pi#311 P4-roles;
|
|
458
|
+
// provider side gentle-ai#3264). An older gentle-ai still renders each one as
|
|
459
|
+
// a self-contained authority-advancing vector: binding tokens plus
|
|
460
|
+
// `--agent=pi --execute=true`, with NO submission descriptor — executing the
|
|
461
|
+
// exact rendered tokens makes Go materialize the role prompt, run its own
|
|
462
|
+
// locked-down pi subprocess, and admit the verdict itself.
|
|
463
|
+
//
|
|
464
|
+
// gentle-ai's v9 contract makes both roles host-mediated exactly like a lens
|
|
465
|
+
// materialize slot instead (gentle-pi#311 P3): the collect input carries
|
|
466
|
+
// `--materialize=true` (never alongside `--execute`) plus a provider-owned
|
|
467
|
+
// `submission` descriptor, and the host completes the frozen prompt
|
|
468
|
+
// in-process and submits the result through that exact descriptor. The two
|
|
469
|
+
// wire forms are mutually exclusive on one input; see the gating in
|
|
470
|
+
// decodeCollectInput below. The known operation set is closed — an unknown
|
|
471
|
+
// role capture operation is never executed either way.
|
|
446
472
|
export const REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION = {
|
|
447
473
|
CAPTURE_REFUTER: "review.capture-refuter",
|
|
448
474
|
CAPTURE_VALIDATION: "review.capture-validation",
|
|
@@ -1451,11 +1477,18 @@ function decodeCaptureSubmission(value: unknown, label: string, v5: boolean, v6:
|
|
|
1451
1477
|
// one-entry values array the host relay already consumes. A payload
|
|
1452
1478
|
// carrying both wire forms at once matches no captured shape and falls
|
|
1453
1479
|
// through to the legacy decoder, which rejects the unknown `value` key.
|
|
1480
|
+
//
|
|
1481
|
+
// gentle-ai's v9 contract renders the same singular-value shape for the
|
|
1482
|
+
// two host-mediated provider role operations (capture-refuter,
|
|
1483
|
+
// capture-validation): they carry a `schema` key exactly like
|
|
1484
|
+
// capture-result, since both bind one artifact-path-or-stdin value, never
|
|
1485
|
+
// the correction-plan's numeric bounds (gentle-pi#311 P3).
|
|
1454
1486
|
if (v5 && typeof value === "object" && value !== null && "value" in value && !("values" in value)) {
|
|
1455
1487
|
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`);
|
|
1488
|
+
const operationToken = enumeration(submission.operation_token, ["capture-result", "capture-correction-plan", "capture-refuter", "capture-validation"] as const, `${label}.operation_token`);
|
|
1457
1489
|
const argumentTokens = stringArray(submission.argument_tokens, `${label}.argument_tokens`, { minimum: 1 });
|
|
1458
|
-
const
|
|
1490
|
+
const bindsArtifactSchema = operationToken === "capture-result" || operationToken === "capture-refuter" || operationToken === "capture-validation";
|
|
1491
|
+
const row = bindsArtifactSchema
|
|
1459
1492
|
? exactRecord(submission.value, `${label}.value`, ["slot", "domain", "schema", "substitution_location"])
|
|
1460
1493
|
: exactRecord(submission.value, `${label}.value`, ["slot", "domain", "minimum", "maximum", "substitution_location"]);
|
|
1461
1494
|
return {
|
|
@@ -1464,7 +1497,11 @@ function decodeCaptureSubmission(value: unknown, label: string, v5: boolean, v6:
|
|
|
1464
1497
|
values: [{
|
|
1465
1498
|
slot: operationToken === "capture-result"
|
|
1466
1499
|
? enumeration(row.slot, ["reviewer_result"] as const, `${label}.value.slot`)
|
|
1467
|
-
:
|
|
1500
|
+
: operationToken === "capture-correction-plan"
|
|
1501
|
+
? enumeration(row.slot, ["correction_lines"] as const, `${label}.value.slot`)
|
|
1502
|
+
: operationToken === "capture-refuter"
|
|
1503
|
+
? enumeration(row.slot, ["provider_refuter"] as const, `${label}.value.slot`)
|
|
1504
|
+
: enumeration(row.slot, ["provider_targeted_validator"] as const, `${label}.value.slot`),
|
|
1468
1505
|
domain: nonempty(row.domain, `${label}.value.domain`),
|
|
1469
1506
|
...(row.schema === undefined ? {} : { schema: nonempty(row.schema, `${label}.value.schema`) }),
|
|
1470
1507
|
...(row.minimum === undefined ? {} : { minimum: integer(row.minimum, `${label}.value.minimum`, 1, 200) }),
|
|
@@ -1605,7 +1642,7 @@ const CORRECTION_REQUEST_REASON_CODES = Object.freeze(["correction_plan_required
|
|
|
1605
1642
|
// v5 capture operations that must carry a submission descriptor.
|
|
1606
1643
|
const V5_SUBMISSION_CAPTURE_OPERATIONS = Object.freeze(["review.capture-correction-plan"] as const);
|
|
1607
1644
|
|
|
1608
|
-
function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: boolean): ReviewCollectInputV3 {
|
|
1645
|
+
function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: boolean, v9: boolean): ReviewCollectInputV3 {
|
|
1609
1646
|
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
1647
|
const name = text(input.name, `${label}.name`, { minimum: 1, pattern: /^[a-z0-9_]+$/ });
|
|
1611
1648
|
const schema = nonempty(input.schema, `${label}.schema`);
|
|
@@ -1656,13 +1693,18 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1656
1693
|
sha256(argumentsList[5]!.value, `${label}.arguments[5].value`);
|
|
1657
1694
|
}
|
|
1658
1695
|
|
|
1659
|
-
// gentle-pi#311 P4-roles: the two
|
|
1660
|
-
// operations render
|
|
1696
|
+
// gentle-pi#311 P4-roles / P3: the two non-lens provider role capture
|
|
1697
|
+
// operations render either wire form on one input, never both at once.
|
|
1698
|
+
// An older gentle-ai renders a SELF-CONTAINED authority-advancing vector
|
|
1699
|
+
// (binding tokens + --agent=pi --execute=true, no submission): executing
|
|
1661
1700
|
// 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
|
-
//
|
|
1701
|
+
// own locked-down pi subprocess, and admit the raw verdict. gentle-ai's
|
|
1702
|
+
// v9 contract instead renders a HOST-MEDIATED form: the same binding
|
|
1703
|
+
// tokens plus --materialize=true (never --execute) and a provider-owned
|
|
1704
|
+
// submission descriptor, exactly like a lens capture-result materialize
|
|
1705
|
+
// slot — the host completes the frozen prompt in-process and submits
|
|
1706
|
+
// through that descriptor.
|
|
1707
|
+
const isRoleCaptureOperation = (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(captureOperation);
|
|
1666
1708
|
if (captureOperation === REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION.CAPTURE_REFUTER && schema !== "https://gentle-ai.dev/schema/review/refuter/v1") {
|
|
1667
1709
|
throw new TypeError(`${label}.schema must be https://gentle-ai.dev/schema/review/refuter/v1`);
|
|
1668
1710
|
}
|
|
@@ -1693,8 +1735,30 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1693
1735
|
if (providerArgument("target") !== validationRequest.correctionTargetIdentity) throw new TypeError(`${label}.arguments target must bind validation_request.correction_target_identity`);
|
|
1694
1736
|
if (providerArgument("request-hash") !== validationRequest.requestHash) throw new TypeError(`${label}.arguments request-hash must bind validation_request.request_hash`);
|
|
1695
1737
|
}
|
|
1696
|
-
if (input.submission !== undefined
|
|
1697
|
-
|
|
1738
|
+
if (isRoleCaptureOperation && input.submission !== undefined) {
|
|
1739
|
+
// A submission descriptor only ever rides the v9 host-mediated form
|
|
1740
|
+
// (gentle-ai's v9 provider contract; the sibling branch ahead of the
|
|
1741
|
+
// currently-released v8). A v8-or-earlier provider (status/v3..v8) never
|
|
1742
|
+
// renders one, even if it happened to carry a submission-shaped payload,
|
|
1743
|
+
// so this is gated on v9 specifically — not on v5, which v8 already
|
|
1744
|
+
// satisfies. Within a v9 payload the discriminator is exactly the
|
|
1745
|
+
// materialize slot's --materialize=true with no --execute, the same one
|
|
1746
|
+
// a lens capture-result materialize slot already uses. Mixing a
|
|
1747
|
+
// submission with the old self-contained --execute=true vector, or with
|
|
1748
|
+
// neither flag, is a malformed vector.
|
|
1749
|
+
if (!v9) {
|
|
1750
|
+
throw new TypeError(`${label}.submission requires the v9 provider contract for the host-mediated ${captureOperation} form`);
|
|
1751
|
+
}
|
|
1752
|
+
const argumentNamed = (argumentName: string): string | undefined => {
|
|
1753
|
+
const matches = argumentsList.filter((argument) => argument.name === argumentName);
|
|
1754
|
+
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
1755
|
+
};
|
|
1756
|
+
if (argumentNamed("materialize") !== "true") {
|
|
1757
|
+
throw new TypeError(`${label}.submission requires --materialize=true on the host-mediated ${captureOperation} form`);
|
|
1758
|
+
}
|
|
1759
|
+
if (argumentNamed("execute") !== undefined) {
|
|
1760
|
+
throw new TypeError(`${label} must not carry --execute alongside a submission descriptor on ${captureOperation}`);
|
|
1761
|
+
}
|
|
1698
1762
|
}
|
|
1699
1763
|
|
|
1700
1764
|
if (captureOperation === "review.capture-result") {
|
|
@@ -1711,7 +1775,12 @@ function decodeCollectInput(value: unknown, label: string, v5: boolean, v6: bool
|
|
|
1711
1775
|
const submissionOperations: readonly string[] = v5
|
|
1712
1776
|
? ["review.capture-result", "review.capture-correction-plan", ...(v6 ? ["external.select_intended_untracked"] : [])]
|
|
1713
1777
|
: ["review.capture-result"];
|
|
1714
|
-
|
|
1778
|
+
// Role operations are gated above by the materialize/execute discriminator
|
|
1779
|
+
// instead of this allowlist: an older gentle-ai's self-contained vector
|
|
1780
|
+
// legitimately carries no submission regardless of v5/v6, so they cannot
|
|
1781
|
+
// simply join the list the way every other submission-carrying operation
|
|
1782
|
+
// does.
|
|
1783
|
+
if (input.submission !== undefined && !isRoleCaptureOperation && !submissionOperations.includes(captureOperation)) {
|
|
1715
1784
|
throw new TypeError(v5 ? `${label}.submission is only valid for ${submissionOperations.join(", ")}` : `${label}.submission is only valid for review.capture-result`);
|
|
1716
1785
|
}
|
|
1717
1786
|
if (intendedUntracked && input.submission === undefined) throw new TypeError(`${label}.submission is required for external.select_intended_untracked`);
|
|
@@ -1749,8 +1818,14 @@ export function decodeReviewManagedAssetsContinuationV1(value: unknown, label: s
|
|
|
1749
1818
|
return { operation, command, ...(agent === undefined ? {} : { agent }), ...(staleAssets === undefined ? {} : { staleAssets }) };
|
|
1750
1819
|
}
|
|
1751
1820
|
|
|
1752
|
-
export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean } = {}): ReviewNextTransitionV3 {
|
|
1753
|
-
|
|
1821
|
+
export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boolean; v6?: boolean; v9?: boolean } = {}): ReviewNextTransitionV3 {
|
|
1822
|
+
// v9 (gentle-ai's next contract, ahead of the currently-released v8) is
|
|
1823
|
+
// the only rung this sub-decoder distinguishes beyond v6: v7 and v8 add
|
|
1824
|
+
// nothing to next_transition/collect-input decoding (v8 only extended the
|
|
1825
|
+
// reviewer-result transition for OpenCode provider tasks at the top
|
|
1826
|
+
// status level), so they decode on the exact v6 surface here.
|
|
1827
|
+
const v9 = options.v9 === true;
|
|
1828
|
+
const v6 = options.v6 === true || v9;
|
|
1754
1829
|
const v5 = options.v5 === true || v6;
|
|
1755
1830
|
const transition = exactRecord(value, "next_transition", ["kind", "reason_code"], ["execute", "collect", ...(v5 ? ["correction_request"] : []), "continuation", "unachievable_lens_slots"]);
|
|
1756
1831
|
const kind = enumeration(transition.kind, ["execute", "collect", "stop"] as const, "next_transition.kind");
|
|
@@ -1812,7 +1887,7 @@ export function decodeReviewNextTransitionV3(value: unknown, options: { v5?: boo
|
|
|
1812
1887
|
}
|
|
1813
1888
|
if (kind === "collect") {
|
|
1814
1889
|
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 });
|
|
1890
|
+
const inputs = array(collect.inputs, "next_transition.collect.inputs", (entry, label) => decodeCollectInput(entry, label, v5, v6, v9), { minimum: 1 });
|
|
1816
1891
|
if (transition.execute !== undefined) throw new TypeError("next_transition.execute is incompatible with collect");
|
|
1817
1892
|
return { kind, reasonCode, collect: { inputs }, ...(correctionRequest === undefined ? {} : { correctionRequest }) };
|
|
1818
1893
|
}
|
|
@@ -1925,16 +2000,25 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1925
2000
|
// surfaces. status/v6 adds the intended-untracked selection; status/v7
|
|
1926
2001
|
// (gentle-ai v2.6.0, advertised through capabilities/v2.5 alongside v6)
|
|
1927
2002
|
// adds optional `eligible_untracked_inventory` and escalation metadata,
|
|
1928
|
-
// so it is decoded on the v6 surface.
|
|
1929
|
-
//
|
|
2003
|
+
// so it is decoded on the v6 surface. status/v8 (gentle-ai main, PR #4765;
|
|
2004
|
+
// the current released contract) only extended the reviewer-result
|
|
2005
|
+
// transition for OpenCode provider tasks -- no new top-level key -- so it
|
|
2006
|
+
// decodes on the exact v7 surface too. status/v9 (the sibling gentle-ai
|
|
2007
|
+
// branch's contract, ahead of the released v8) adds nothing at this top
|
|
2008
|
+
// level either: its one addition is the host-mediated role `submission`
|
|
2009
|
+
// on a next_transition.collect input (gentle-pi#311 P3), gated on v9 in
|
|
2010
|
+
// decodeCollectInput, not here. v3 keeps rejecting every
|
|
2011
|
+
// v5/v6/v7/v8/v9-only field.
|
|
1930
2012
|
const schema = typeof value === "object" && value !== null ? (value as Record<string, unknown>).schema : undefined;
|
|
1931
|
-
const
|
|
2013
|
+
const v9 = schema === "gentle-ai.review-integration.status/v9";
|
|
2014
|
+
const v8 = v9 || schema === "gentle-ai.review-integration.status/v8";
|
|
2015
|
+
const v7 = v8 || schema === "gentle-ai.review-integration.status/v7";
|
|
1932
2016
|
const v6 = v7 || schema === "gentle-ai.review-integration.status/v6";
|
|
1933
2017
|
const v5 = v6 || schema === "gentle-ai.review-integration.status/v5";
|
|
1934
2018
|
const body = exactRecord(value, "status", [
|
|
1935
2019
|
"schema", "contract", "operation", "applicability", "action", "replayability", "target_identity", "projection", "repair", "candidates",
|
|
1936
2020
|
], ["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);
|
|
2021
|
+
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
2022
|
|
|
1939
2023
|
const applicability = enumeration(body.applicability, ["current_target", "unrelated", "ambiguous", "corrupted"] as const, "status.applicability");
|
|
1940
2024
|
let receipt: ReviewStatusReceiptV1 | undefined;
|
|
@@ -1982,7 +2066,7 @@ export function decodeReviewStatusV3(value: unknown): ReviewStatusV3 {
|
|
|
1982
2066
|
if (action === "recover" && actionDisposition === undefined) throw new TypeError("recover status requires action_disposition");
|
|
1983
2067
|
if (action !== "recover" && actionDisposition !== undefined) throw new TypeError("status.action_disposition is only valid for the recover action");
|
|
1984
2068
|
if (body.eligibility !== undefined) decodeEligibility(body.eligibility, "status.eligibility");
|
|
1985
|
-
const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6 });
|
|
2069
|
+
const nextTransition = body.next_transition === undefined ? undefined : decodeReviewNextTransitionV3(body.next_transition, { v5, v6, v9 });
|
|
1986
2070
|
const validationRequest = v5 && body.validation_request !== undefined
|
|
1987
2071
|
? decodeReviewTargetedValidationRequestV1(body.validation_request, "status.validation_request")
|
|
1988
2072
|
: 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 { renderUsageBar, type ProviderUsage } from "./shell-usage.ts";
|
|
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;
|
|
@@ -48,7 +85,7 @@ const ROLE = {
|
|
|
48
85
|
SESSION: "dim",
|
|
49
86
|
} as const;
|
|
50
87
|
|
|
51
|
-
export const SHELL_BAR_BRAND = "✿ gentle
|
|
88
|
+
export const SHELL_BAR_BRAND = "✿ gentle shell";
|
|
52
89
|
export const SHELL_BAR_SEPARATOR = "⟡";
|
|
53
90
|
export const SHELL_BAR_GAUGE_CELLS = GAUGE_CELLS;
|
|
54
91
|
const RIGHT_PADDING = 2;
|
|
@@ -79,18 +116,37 @@ function sanitizeStatus(text: string): string {
|
|
|
79
116
|
return sanitizeTerminalText(text.replace(/[\r\n\t]/g, " ")).replace(/ +/g, " ").trim();
|
|
80
117
|
}
|
|
81
118
|
|
|
82
|
-
|
|
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 {
|
|
83
123
|
const dirty = model.dirty ? ` ${theme.fg(ROLE.DIRTY, `±${model.dirty}`)}` : "";
|
|
84
|
-
|
|
124
|
+
return model.branch
|
|
85
125
|
? `${theme.fg(ROLE.PATH, model.cwd)} ${theme.fg(ROLE.BRANCH, model.branch)}${dirty}`
|
|
86
126
|
: theme.fg(ROLE.PATH, model.cwd) + dirty;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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);
|
|
149
|
+
const usage = model.usage ? renderUsageBar(model.usage, theme, model.modelId) : undefined;
|
|
94
150
|
const statuses = model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)));
|
|
95
151
|
return [theme.fg(ROLE.BRAND, SHELL_BAR_BRAND), location, modelSegment, context, cost, ...(usage ? [usage] : []), ...statuses];
|
|
96
152
|
}
|
|
@@ -126,9 +182,14 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
126
182
|
const label = (text: string) => theme.fg(ROLE.LABEL, text);
|
|
127
183
|
const changes = model.changes;
|
|
128
184
|
const branch = model.branch ? `${label("Branch")} ${value(model.branch)}` : "";
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
185
|
+
// Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
|
|
186
|
+
// the same inset without consuming the card's right border.
|
|
187
|
+
const innerWidth = cardInnerWidth(width);
|
|
188
|
+
const inset = Math.min(1, innerWidth - 1);
|
|
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.
|
|
132
193
|
const groups: Array<{ title: string; lines: string[] }> = [
|
|
133
194
|
{
|
|
134
195
|
title: "Project",
|
|
@@ -136,8 +197,6 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
136
197
|
value(model.cwd),
|
|
137
198
|
...(branch ? [branch] : []),
|
|
138
199
|
...(model.sessionName ? [`${label("Session")} ${value(model.sessionName)}`] : []),
|
|
139
|
-
`${label("Model")} ${value(model.modelId)}`,
|
|
140
|
-
...(model.effort ? [`${label("Effort")} ${theme.fg(ROLE.EFFORT, model.effort)}`] : []),
|
|
141
200
|
...(model.profile ? [`${label("Profile")} ${value(sanitizeStatus(model.profile))}`] : []),
|
|
142
201
|
],
|
|
143
202
|
},
|
|
@@ -151,23 +210,12 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
151
210
|
label("/gentle:changes"),
|
|
152
211
|
],
|
|
153
212
|
},
|
|
154
|
-
{
|
|
155
|
-
title: "Usage",
|
|
156
|
-
lines: [
|
|
157
|
-
`${label("Context")} ${paintGauge(model.contextPercent, theme)} ${value(percent)}`,
|
|
158
|
-
capacity,
|
|
159
|
-
`${label("Cost")} ${value(formatCost(model.costTotal, model.subscription))}`,
|
|
160
|
-
...(usage ? [usage] : []),
|
|
161
|
-
],
|
|
162
|
-
},
|
|
163
213
|
{ title: "Integrations", lines: model.statuses.length
|
|
164
214
|
? model.statuses.map((status) => theme.fg(ROLE.STATUS, sanitizeStatus(status)))
|
|
165
215
|
: [label("No status reported")] },
|
|
166
216
|
];
|
|
167
|
-
//
|
|
168
|
-
// the same inset without consuming the
|
|
169
|
-
const innerWidth = cardInnerWidth(width);
|
|
170
|
-
const inset = Math.min(1, innerWidth - 1);
|
|
217
|
+
// Wrap and indent every group line before it reaches the card, so Unicode and
|
|
218
|
+
// ANSI continuation lines keep the same inset without consuming the right border.
|
|
171
219
|
const body = groups.flatMap((group, index) => [
|
|
172
220
|
...(index ? [""] : []),
|
|
173
221
|
label(group.title),
|
|
@@ -176,6 +224,87 @@ export function renderShellSidebarBar(model: ShellBarModel, theme: ShellBarTheme
|
|
|
176
224
|
return renderCard({ title: "Status", body, tone: CARD_TONE.INFO }, theme, width, { expanded: true });
|
|
177
225
|
}
|
|
178
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
|
+
|
|
179
308
|
export function renderShellBar(model: ShellBarModel, theme: ShellBarTheme, width: number): string[] {
|
|
180
309
|
let segments = buildSegments(model, theme);
|
|
181
310
|
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
|
}
|