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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. 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 Go-owned non-lens provider role capture operations (gentle-pi#311
442
- // P4-roles; provider side gentle-ai#3264). Their collect inputs are
443
- // self-contained authority-advancing vectors: binding tokens plus
444
- // `--agent=pi --execute=true`, with NO submission descriptor. The known set
445
- // is closed an unknown role capture operation is never executed.
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 row = operationToken === "capture-result"
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
- : enumeration(row.slot, ["correction_lines"] as const, `${label}.value.slot`),
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 Go-owned non-lens provider role capture
1660
- // operations render SELF-CONTAINED authority-advancing vectors. Executing
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 — so a
1663
- // submission descriptor (the host-mediated completing form) on one of
1664
- // these inputs would hand the caller a way to author the verdict and is
1665
- // rejected as a provider contract violation.
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 && (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(captureOperation)) {
1697
- throw new TypeError(`${label}.submission is not allowed on the self-contained ${captureOperation} vector`);
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
- if (input.submission !== undefined && !submissionOperations.includes(captureOperation)) {
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
- const v6 = options.v6 === true;
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. v3 keeps rejecting every v5/v6/v7-only
1929
- // field.
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 v7 = schema === "gentle-ai.review-integration.status/v7";
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-pi";
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
- function buildSegments(model: ShellBarModel, theme: ShellBarTheme): string[] {
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
- const location = model.branch
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
- const modelSegment = model.effort
88
- ? `${theme.fg(ROLE.MODEL, model.modelId)} ${theme.fg(ROLE.LABEL, "·")} ${theme.fg(ROLE.EFFORT, model.effort)}`
89
- : theme.fg(ROLE.MODEL, model.modelId);
90
- const percentText = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
91
- const context = `${theme.fg(ROLE.LABEL, "ctx")} ${paintGauge(model.contextPercent, theme)} ${theme.fg(ROLE.VALUE, percentText)}`;
92
- const cost = theme.fg(ROLE.VALUE, formatCost(model.costTotal, model.subscription));
93
- const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
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
- const percent = model.contextPercent === null ? "?%" : `${Math.round(model.contextPercent)}%`;
130
- const capacity = label(`${formatTokens(model.contextWindow)} tokens`);
131
- const usage = model.usage ? renderUsageBar(model.usage, theme) : undefined;
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
- // Pre-wrap values before indenting so Unicode/ANSI continuation lines keep
168
- // the same inset without consuming the card's right border.
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
- const TONE_ROLE: Record<CardTone, string> = {
36
- [CARD_TONE.INFO]: "customMessageLabel",
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(TONE_ROLE[card.tone], head)} ${theme.fg(SUBTITLE_ROLE, SEPARATOR)} ${theme.fg(SUBTITLE_ROLE, card.subtitle)}`
55
- : theme.fg(TONE_ROLE[card.tone], head);
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(TONE_ROLE[tone], text);
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(TONE_ROLE[card.tone], "╭");
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(TONE_ROLE[card.tone], "╭") + frame(theme, card.tone, "─ ") + styledTitle + frame(theme, card.tone, ` ${fill}`) + tail + frame(theme, card.tone, "╮");
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(TONE_ROLE[tone], "│");
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(TONE_ROLE[tone], "╰");
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
  }