gentle-pi 2.6.4 → 3.0.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 (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -1,20 +1,26 @@
1
1
  import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
3
3
  import { execFile, spawnSync } from "node:child_process";
4
- import { readFile } from "node:fs/promises";
4
+ import { statSync } from "node:fs";
5
+ import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
5
6
  import * as os from "node:os";
6
7
  import { join } from "node:path";
7
8
  import { renderShellBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
8
- import { CHANGE_STATUS, WorktreeChangesTracker, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type LineCounter, type WorktreeChanges } from "../lib/shell-changes.ts";
9
+ import { CHANGE_STATUS, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
9
10
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
10
- import { SessionWorktreeRegistry, SESSION_WORKTREE_CHANGED, resolveSessionWorktree, toolWorktreePath, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
11
+ import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
11
12
  import { CARD_TONE, renderCard, type Card, type CardTheme } from "../lib/shell-card.ts";
13
+ import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
14
+ import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
15
+ import { agentsViewKey } from "../lib/agents-keys.ts";
12
16
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
13
- import { framePromptLines, PROMPT_HINT, PROMPT_STATE, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
17
+ import { framePromptLines, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
14
18
  import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, parseCodexUsage, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
15
19
  import { UsageView } from "../lib/shell-usage-view.ts";
16
20
  import { sidebarPart } from "../lib/shell-sidebar.ts";
17
21
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
22
+ import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
23
+ import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
18
24
 
19
25
  // Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
20
26
  // status bar, the petal prompt, the working-tree changes widget and overlay,
@@ -39,6 +45,7 @@ interface ShellBarComponent {
39
45
  }
40
46
 
41
47
  interface BuildOptions {
48
+ profile?: string;
42
49
  home?: string;
43
50
  dirty?: number;
44
51
  usage?: ProviderUsage;
@@ -47,6 +54,7 @@ interface BuildOptions {
47
54
  export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
48
55
 
49
56
  export interface ShellDeps {
57
+ activeProfile(): string | undefined;
50
58
  fetch: typeof fetch;
51
59
  now(): number;
52
60
  devBinary(): DevBinaryNotice | undefined;
@@ -54,6 +62,31 @@ export interface ShellDeps {
54
62
  gitRunner(cwd: string): GitRunner;
55
63
  }
56
64
 
65
+ // The rail digest runs every frame. Cache parsing by file identity and metadata,
66
+ // not just mtime: profile writes replace the store atomically. Keep the cache
67
+ // local to this shell instance and recheck on the next frame after panel edits.
68
+ export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): () => string | undefined {
69
+ const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
70
+ let fingerprint: string | undefined;
71
+ let name: string | undefined;
72
+ return () => {
73
+ try {
74
+ const stat = statSync(path, { bigint: true });
75
+ const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
76
+ if (next !== fingerprint) {
77
+ const result = readProfilesFileResult(path);
78
+ name = result.status === "valid" ? result.file.active : undefined;
79
+ fingerprint = next;
80
+ }
81
+ return name;
82
+ } catch {
83
+ fingerprint = undefined;
84
+ name = undefined;
85
+ return undefined;
86
+ }
87
+ };
88
+ }
89
+
57
90
  function ambientDevBinary(): DevBinaryNotice | undefined {
58
91
  try {
59
92
  const override = resolveGentleAiDevBinaryOverride();
@@ -64,7 +97,7 @@ function ambientDevBinary(): DevBinaryNotice | undefined {
64
97
  }
65
98
  }
66
99
 
67
- const defaultShellDeps: ShellDeps = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
100
+ const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (...args) => globalThis.fetch(...args), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
68
101
 
69
102
  interface AssistantUsageEntry {
70
103
  type: string;
@@ -104,6 +137,7 @@ export function buildShellBarModel(
104
137
  .map(([, text]) => text);
105
138
  return {
106
139
  cwd: shortenHome(ctx.sessionManager.getCwd(), home),
140
+ profile: options.profile,
107
141
  branch: footerData.getGitBranch(),
108
142
  dirty: options.dirty,
109
143
  sessionName: ctx.sessionManager.getSessionName(),
@@ -151,8 +185,6 @@ interface PromptEditorDeps {
151
185
 
152
186
  const PROMPT_FRAME_ROLE = "border";
153
187
 
154
- const PETAL_PULSE_MS = 160;
155
-
156
188
  export class GentlePromptEditor extends CustomEditor {
157
189
  private promptState: PromptState = PROMPT_STATE.IDLE;
158
190
  private tick = 0;
@@ -171,7 +203,7 @@ export class GentlePromptEditor extends CustomEditor {
171
203
  this.pulse = setInterval(() => {
172
204
  this.tick += 1;
173
205
  this.deps.requestRender();
174
- }, PETAL_PULSE_MS);
206
+ }, SHELL_PULSE_MS);
175
207
  this.pulse.unref();
176
208
  }
177
209
  this.deps.requestRender();
@@ -203,9 +235,14 @@ export class GentlePromptEditor extends CustomEditor {
203
235
  }
204
236
  }
205
237
 
206
- function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): void {
207
- if (ctx.ui.getEditorComponent()) return;
208
- ctx.ui.setEditorComponent((tui, theme, keybindings) => {
238
+ // Stable across extension module reloads; never infer ownership from a name.
239
+ const PROMPT_OWNER = Symbol.for("gentle-pi.prompt-owner");
240
+ type PromptFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>> & { [PROMPT_OWNER]?: boolean };
241
+
242
+ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEditor) => void): boolean {
243
+ const previous = ctx.ui.getEditorComponent() as PromptFactory | undefined;
244
+ if (previous && !previous[PROMPT_OWNER]) return false;
245
+ const factory: PromptFactory = (tui, theme, keybindings) => {
209
246
  const prompt = new GentlePromptEditor(tui, theme, keybindings, {
210
247
  fg: (color, text) => ctx.ui.theme.fg(color as Parameters<typeof ctx.ui.theme.fg>[0], text),
211
248
  bold: (text) => ctx.ui.theme.bold(text),
@@ -214,15 +251,18 @@ function installPrompt(ctx: ExtensionContext, onCreated: (prompt: GentlePromptEd
214
251
  });
215
252
  onCreated(prompt);
216
253
  return prompt;
217
- });
254
+ };
255
+ factory[PROMPT_OWNER] = true;
256
+ ctx.ui.setEditorComponent(factory);
257
+ return true;
218
258
  }
219
259
 
220
260
  const CHANGES_WIDGET_KEY = "gentle-shell-changes";
221
261
  const CHANGES_COMMAND_NAME = "gentle:changes";
222
262
  const CHANGES_SHORTCUT_DEFAULT = "alt+g";
223
263
  const CHANGES_POLL_DEFAULT_MS = 2000;
224
- const CHANGES_WATCH_DEFAULT_MS = 5000;
225
264
  const GIT_TIMEOUT_MS = 5000;
265
+ const COMMANDS_COMMAND_NAME = "gentle:commands";
226
266
  const OVERLAY_HEIGHT_RATIO = 0.8;
227
267
  const OVERLAY_MIN_ROWS = 8;
228
268
 
@@ -246,14 +286,6 @@ export function shellGitRunner(cwd: string, env: NodeJS.ProcessEnv = process.env
246
286
  });
247
287
  }
248
288
 
249
- function lineCounter(cwd: string): LineCounter {
250
- return async (path: string) => {
251
- const text = await readFile(join(cwd, path), "utf8");
252
- if (text.length === 0) return 0;
253
- return text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
254
- };
255
- }
256
-
257
289
  export async function loadFileDiff(git: GitRunner, file: ChangedFile): Promise<string> {
258
290
  const args = file.status === CHANGE_STATUS.UNTRACKED ? ["diff", "--no-index", "--", "/dev/null", file.path] : ["diff", "HEAD", "--", file.path];
259
291
  const result = await git(args);
@@ -295,28 +327,19 @@ function changesPollMs(env: NodeJS.ProcessEnv): number {
295
327
  return positiveMs(env.GENTLE_PI_SHELL_CHANGES_POLL_MS, CHANGES_POLL_DEFAULT_MS);
296
328
  }
297
329
 
298
- // Background watch: the widget and the bar follow edits made outside pi.
299
- // 0 or "off" disables it; tool events still refresh.
300
- function changesWatchMs(env: NodeJS.ProcessEnv): number | undefined {
301
- const value = env.GENTLE_PI_SHELL_CHANGES_WATCH_MS?.trim().toLowerCase();
302
- if (value === "0" || value === "off") return undefined;
303
- return positiveMs(value, CHANGES_WATCH_DEFAULT_MS);
304
- }
305
-
306
330
  function changesFingerprint(model: ChangesModel): string {
307
- return model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}`).join("|");
331
+ return [model.notice ?? "", ...model.files.map((file) => `${file.path}:${file.status}:${file.added}:${file.deleted}:${file.diffRevision ?? ""}:${file.countsUnavailable ?? ""}`)].join("|");
308
332
  }
309
333
 
310
334
  interface OverlayDeps {
311
- git(root: string): GitRunner;
335
+ loadDiff(root: string, file: ChangedFile): string;
312
336
  worktrees(): WorktreeChanges[];
313
337
  refresh(): Promise<ChangesModel>;
314
338
  apply(ctx: ExtensionContext, model: ChangesModel): void;
315
339
  pollMs: number;
316
340
  }
317
341
 
318
- // While the overlay is open, git is polled so edits made outside pi (nvim,
319
- // another agent, a git checkout) show up without reopening it.
342
+ // Refresh only the captured session model. Never read live files or Git here.
320
343
  async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Promise<void> {
321
344
  let host: ExternalEditorHost | undefined;
322
345
  let view: WorktreeChangesView | undefined;
@@ -334,7 +357,7 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
334
357
  view = new WorktreeChangesView(deps.worktrees(), {
335
358
  theme,
336
359
  rows: () => Math.max(OVERLAY_MIN_ROWS, Math.floor(tui.terminal.rows * OVERLAY_HEIGHT_RATIO)),
337
- loadDiff: (root, file) => loadFileDiff(deps.git(root), file),
360
+ loadDiff: (root, file) => Promise.resolve(deps.loadDiff(root, file)),
338
361
  onOpen: (root, file) => done({ root, file }),
339
362
  onRefresh: () => void refresh(),
340
363
  onClose: () => done(null),
@@ -353,6 +376,29 @@ async function showChangesOverlay(ctx: ExtensionContext, deps: OverlayDeps): Pro
353
376
  }
354
377
  }
355
378
 
379
+ // The command palette is a curated, grouped menu (Configuration, Session,
380
+ // Diagnostics, SDD, Skills), not a raw listing of every registered
381
+ // extension command: buildCommandPaletteGroups keeps only the catalog
382
+ // entries that are actually registered, so a missing extension never shows
383
+ // a dead row. Selecting an entry runs it exactly as if the user had typed
384
+ // the underlying slash command.
385
+ async function showCommandPalette(pi: ExtensionAPI, ctx: ExtensionContext, env: NodeJS.ProcessEnv): Promise<void> {
386
+ if (!ctx.hasUI) return;
387
+ const groups = buildCommandPaletteGroups(pi.getCommands(), {
388
+ "gentle:changes": changesShortcut(env),
389
+ "gentle:agents": agentsViewKey(env),
390
+ });
391
+ if (groups.length === 0) {
392
+ ctx.ui.notify("No Gentle commands are registered.", "info");
393
+ return;
394
+ }
395
+ const result = await ctx.ui.custom<CommandPaletteResult>(
396
+ (tui, theme, _keybindings, done) => new CommandPalette(groups, done, theme, () => Math.max(0, tui.terminal.rows)),
397
+ { overlay: true, overlayOptions: { anchor: "center", width: "70%", minWidth: 60, maxHeight: "85%" } },
398
+ );
399
+ if (result?.type === "run") pi.sendUserMessage(`/${result.name}`, { expandPromptTemplates: true });
400
+ }
401
+
356
402
  function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
357
403
  if (model.files.length === 0) {
358
404
  ctx.ui.setWidget(CHANGES_WIDGET_KEY, undefined);
@@ -365,20 +411,6 @@ function showChanges(ctx: ExtensionContext, model: ChangesModel): void {
365
411
  return renderChangesWidget(model, theme, width);
366
412
  },
367
413
  invalidate() {},
368
- }, {
369
- render(width: number) {
370
- const noun = model.files.length === 1 ? "file" : "files";
371
- return renderCard({
372
- title: "Changes",
373
- tone: CARD_TONE.INFO,
374
- body: [
375
- `${model.files.length} ${noun} · ${theme.fg("success", `+${model.added}`)} ${theme.fg("error", `−${model.deleted}`)}`,
376
- "",
377
- theme.fg("muted", `/${CHANGES_COMMAND_NAME}`),
378
- ],
379
- }, theme, width, { expanded: true });
380
- },
381
- invalidate() {},
382
414
  }),
383
415
  { placement: "belowEditor" },
384
416
  );
@@ -451,8 +483,9 @@ export async function fetchCodexUsage(token: string | undefined, fetchFn: typeof
451
483
  }
452
484
 
453
485
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
486
+ installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
454
487
  if (!shellEnabled(env)) return;
455
- const deps: ShellDeps = { ...defaultShellDeps, ...overrides };
488
+ const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
456
489
  const usage = new UsageStore();
457
490
  let renderHost: ShellRenderHost | undefined;
458
491
  let usageFetchedAt = 0;
@@ -500,11 +533,9 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
500
533
  },
501
534
  });
502
535
  let prompt: GentlePromptEditor | undefined;
503
- let changes: WorktreeChangesTracker | undefined;
536
+ let changes: SessionChanges | undefined;
504
537
  let registry: SessionWorktreeRegistry | undefined;
505
538
  let currentContext: ExtensionContext | undefined;
506
- const pendingTools = new Map<string, { sessionId: string; path?: string }>();
507
- let watch: NodeJS.Timeout | undefined;
508
539
  let shown = "";
509
540
  const applyChanges = (ctx: ExtensionContext, model: ChangesModel) => {
510
541
  const fingerprint = changesFingerprint(model);
@@ -515,22 +546,21 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
515
546
  const refreshChanges = async (ctx: ExtensionContext) => {
516
547
  const tracker = changes;
517
548
  if (!tracker || !ctx.hasUI || registry?.sessionId !== ctx.sessionManager.getSessionId()) return;
549
+ tracker.restore(ctx.sessionManager.getEntries());
518
550
  const model = await tracker.refresh();
519
551
  if (changes === tracker) applyChanges(ctx, model);
520
552
  };
521
- const stopWatch = () => {
522
- if (watch) clearInterval(watch);
523
- watch = undefined;
524
- };
525
- const unsubscribeWorktrees = pi.events.on(SESSION_WORKTREE_CHANGED, (data) => {
553
+ const unsubscribeWorktrees = pi.events.on(SESSION_CHANGE_EVENT, (data) => {
526
554
  if (!currentContext || !registry || (data as { sessionId?: string } | undefined)?.sessionId !== registry.sessionId) return;
555
+ const notice = (data as { notice?: string } | undefined)?.notice;
556
+ if (notice) { if (changes) changes.notice = notice; currentContext.ui.notify(notice, "warning"); }
527
557
  void refreshChanges(currentContext);
528
558
  });
529
559
  pi.registerTool({
530
560
  name: "session_worktree_register",
531
561
  renderShell: "self",
532
562
  label: "Register session worktree",
533
- description: "Register a worktree used by this session, including earlier work or opaque shell use. Only the same Git clone is accepted. Shows ALL dirty files in that root, including preexisting and untracked files; never infers roots from shell commands or prose.",
563
+ description: "Register a worktree in the same Git clone for session coordination. Registration does not attribute file changes; Changes shows captured write/edit operations only.",
534
564
  parameters: { type: "object", required: ["path"], additionalProperties: false, properties: { path: { type: "string", description: "Worktree path to include in this session." } } } as never,
535
565
  async execute(_id, params, _signal, _onUpdate, ctx) {
536
566
  if (!registry || registry.sessionId !== ctx.sessionManager.getSessionId()) throw new Error("No active session worktree registry.");
@@ -540,16 +570,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
540
570
  },
541
571
  });
542
572
  pi.on("session_start", async (_event, ctx) => {
543
- stopWatch();
544
573
  registry?.close();
545
- pendingTools.clear();
546
574
  currentContext = ctx;
547
575
  changes = undefined;
548
576
  registry = new SessionWorktreeRegistry(pi, ctx.sessionManager, ctx.cwd, deps.resolveWorktree);
549
577
  registry.start();
550
- const sessionRegistry = registry;
551
578
  if (!ctx.hasUI) return;
552
- changes = new WorktreeChangesTracker(deps.gitRunner(ctx.cwd), deps.gitRunner, lineCounter, () => sessionRegistry.roots());
579
+ changes = new SessionChanges(ctx.sessionManager.getSessionId(), ctx.sessionManager.getEntries());
553
580
  const tracker = changes;
554
581
  ctx.ui.setFooter((tui, theme, footerData) => {
555
582
  renderHost = { requestRender: () => tui.requestRender(), invalidateSidebar: () => invalidateSidebar(tui) };
@@ -558,7 +585,10 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
558
585
  // part for: model, effort, context, cost, session name and extension
559
586
  // statuses. The digest is what keeps the fullscreen memo honest, and it
560
587
  // rebuilds the model exactly as the narrow bottom bar does every frame.
561
- const footerModel = () => buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? "") });
588
+ const footerModel = (): ShellBarModel => ({
589
+ ...buildShellBarModel(pi, ctx, footerData, { dirty: tracker.model.files.length, usage: usage.get(ctx.model?.provider ?? ""), profile: deps.activeProfile() }),
590
+ changes: { files: tracker.model.files.length, added: tracker.model.added, deleted: tracker.model.deleted, notice: tracker.model.notice },
591
+ });
562
592
  const part = sidebarPart(tui, "footer", bottom, {
563
593
  digest: () => JSON.stringify(footerModel()),
564
594
  render: (width) => renderShellSidebarBar(footerModel(), theme, width),
@@ -568,11 +598,13 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
568
598
  return { ...part, dispose() { uninstall(); part.dispose(); } };
569
599
  });
570
600
  void refreshUsage(ctx, true);
571
- installPrompt(ctx, (created) => {
601
+ const ownsPrompt = installPrompt(ctx, (created) => {
602
+ prompt?.dispose();
572
603
  prompt = created;
573
604
  });
574
- // The petal already says the agent is working; pi's own "Working" row would say it twice.
575
- ctx.ui.setWorkingVisible(false);
605
+ // Hide native feedback only when our petal replaces it. Native transcript
606
+ // thinking blocks remain Pi-owned; this changes only the supported loader UI.
607
+ if (ownsPrompt) ctx.ui.setWorkingVisible(false);
576
608
  const notice = deps.devBinary();
577
609
  ctx.ui.setWidget(
578
610
  DEV_BINARY_WIDGET_KEY,
@@ -580,24 +612,21 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
580
612
  ? (_tui, theme) => spaced(cardComponent(devBinaryCard(notice), theme, { expanded: true }))
581
613
  : undefined,
582
614
  );
583
- await tracker.start();
584
615
  if (changes !== tracker) return;
585
616
  shown = "";
586
617
  applyChanges(ctx, tracker.model);
587
- stopWatch();
588
- const watchMs = changesWatchMs(env);
589
- if (watchMs) {
590
- watch = setInterval(() => void refreshChanges(ctx), watchMs);
591
- watch.unref();
592
- }
593
618
  });
594
- pi.on("session_shutdown", () => {
595
- stopWatch();
619
+ pi.on("session_shutdown", (_event, ctx) => {
620
+ prompt?.dispose();
621
+ prompt = undefined;
622
+ if ((ctx.ui.getEditorComponent() as PromptFactory | undefined)?.[PROMPT_OWNER]) {
623
+ ctx.ui.setEditorComponent(undefined);
624
+ ctx.ui.setWorkingVisible(true);
625
+ }
596
626
  registry?.close();
597
627
  registry = undefined;
598
628
  changes = undefined;
599
629
  currentContext = undefined;
600
- pendingTools.clear();
601
630
  unsubscribeWorktrees();
602
631
  });
603
632
  const openChanges = async (ctx: ExtensionContext) => {
@@ -605,46 +634,43 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
605
634
  const tracker = changes;
606
635
  const model = await tracker.refresh();
607
636
  if (model.files.length === 0) {
608
- ctx.ui.notify("No changes in the working tree.", "info");
637
+ ctx.ui.notify("No captured agent changes. Only successful write/edit operations from this session and its subagents are shown; shell changes are not attributed.", "info");
609
638
  return;
610
639
  }
611
- await showChangesOverlay(ctx, { git: deps.gitRunner, worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
640
+ await showChangesOverlay(ctx, { loadDiff: (root, file) => tracker.loadDiff(root, file), worktrees: () => tracker.worktrees, refresh: () => tracker.refresh(), apply: applyChanges, pollMs: changesPollMs(env) });
612
641
  };
613
642
  pi.registerCommand(CHANGES_COMMAND_NAME, {
614
- description: "Browse this session's registered dirty worktrees and their changes against HEAD. Press o on a file to open $EDITOR.",
643
+ description: "Browse captured write/edit changes from this agent session and its subagents, excluding preexisting and external edits. Shell changes are not attributed. Press o to open $EDITOR.",
615
644
  handler: async (_args, ctx) => openChanges(ctx),
616
645
  });
617
646
  const shortcut = changesShortcut(env);
618
647
  if (shortcut) {
619
648
  pi.registerShortcut(shortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
620
- description: "Open the working tree changes",
649
+ description: "Open captured agent session changes",
621
650
  handler: async (ctx) => openChanges(ctx),
622
651
  });
623
652
  }
653
+ pi.registerCommand(COMMANDS_COMMAND_NAME, {
654
+ description: "Open the command palette: a curated, grouped menu of Gentle commands.",
655
+ handler: async (_args, ctx) => showCommandPalette(pi, ctx, env),
656
+ });
657
+ const commandsShortcut = commandsKey(env);
658
+ if (commandsShortcut) {
659
+ pi.registerShortcut(commandsShortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
660
+ description: "Open the command palette",
661
+ handler: async (ctx) => showCommandPalette(pi, ctx, env),
662
+ });
663
+ }
624
664
  pi.on("agent_start", (_event, ctx) => {
625
665
  prompt?.setWorking(true);
626
666
  // The dev-binary card is a startup notice: it leaves with the first prompt.
627
667
  if (ctx.hasUI) ctx.ui.setWidget(DEV_BINARY_WIDGET_KEY, undefined);
628
668
  });
629
- pi.on("agent_end", async (_event, ctx) => {
669
+ pi.on("agent_settled", () => {
630
670
  prompt?.setWorking(false);
631
- await refreshChanges(ctx);
632
- void refreshUsage(ctx, false);
633
- });
634
- pi.on("tool_execution_start", (event, ctx) => {
635
- pendingTools.set(event.toolCallId, { sessionId: ctx.sessionManager.getSessionId() });
636
671
  });
637
- pi.on("tool_result", (event, ctx) => {
638
- const pending = pendingTools.get(event.toolCallId);
639
- if (pending?.sessionId === ctx.sessionManager.getSessionId()) pending.path = toolWorktreePath(event.toolName, event.input);
640
- });
641
- pi.on("tool_execution_end", async (event, ctx) => {
642
- const pending = pendingTools.get(event.toolCallId);
643
- pendingTools.delete(event.toolCallId);
644
- if (!event.isError && pending?.path !== undefined && pending.sessionId === registry?.sessionId && pending.sessionId === ctx.sessionManager.getSessionId()) {
645
- try { registry.register(pending.path, `tool:${event.toolName}`); }
646
- catch { /* Non-project paths and missing roots do not expand the registry. */ }
647
- }
672
+ pi.on("agent_end", async (_event, ctx) => {
648
673
  await refreshChanges(ctx);
674
+ void refreshUsage(ctx, false);
649
675
  });
650
676
  }
@@ -1,29 +1,78 @@
1
- import { realpathSync } from "node:fs";
2
- import { createRequire } from "node:module";
3
- import { fileURLToPath } from "node:url";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { shellEnabled } from "../lib/shell-bar.ts";
4
3
  import {
5
4
  mergeDisabledTools,
6
5
  PI_PRETTY_SUPPRESSED_TOOL_NAMES,
7
6
  quietToolsEnabled,
8
7
  } from "../lib/quiet-tools-config.ts";
9
8
 
10
- const packageJsonPath = realpathSync(
11
- fileURLToPath(new URL("../package.json", import.meta.url)),
12
- );
13
- const requireFromRealPackage = createRequire(packageJsonPath);
14
- const piPrettyModule = requireFromRealPackage("@heyhuynhgiabuu/pi-pretty");
9
+ type PiPrettyExtension = (pi: unknown, deps?: unknown) => unknown;
15
10
 
16
- const piPrettyExtension =
17
- typeof piPrettyModule === "function"
18
- ? piPrettyModule
19
- : piPrettyModule.default;
11
+ let piPrettyExtensionPromise: Promise<PiPrettyExtension> | undefined;
20
12
 
21
- export default async function gentlePiPrettyExtension(pi: unknown, deps?: unknown): Promise<unknown> {
13
+ async function loadPiPrettyExtension(): Promise<PiPrettyExtension> {
14
+ return piPrettyExtensionPromise ??= import("@heyhuynhgiabuu/pi-pretty").then(
15
+ (piPrettyModule) => {
16
+ const moduleCandidate: unknown = piPrettyModule;
17
+ const extension =
18
+ typeof moduleCandidate === "function"
19
+ ? moduleCandidate
20
+ : piPrettyModule.default;
21
+ if (typeof extension !== "function") {
22
+ throw new TypeError("pi-pretty must export an extension function");
23
+ }
24
+ return extension as PiPrettyExtension;
25
+ },
26
+ );
27
+ }
28
+
29
+ export default async function gentlePiPrettyExtension(
30
+ pi: unknown,
31
+ deps?: unknown,
32
+ bundled?: PiPrettyExtension,
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): Promise<unknown> {
22
35
  if (quietToolsEnabled()) {
23
36
  process.env.PRETTY_DISABLE_TOOLS = mergeDisabledTools(
24
37
  process.env.PRETTY_DISABLE_TOOLS,
25
38
  PI_PRETTY_SUPPRESSED_TOOL_NAMES,
26
39
  );
27
40
  }
28
- return piPrettyExtension(pi, deps);
41
+ const extension = bundled ?? await loadPiPrettyExtension();
42
+ if (!shellEnabled(env)) return extension(pi, deps);
43
+ const api = pi as ExtensionAPI;
44
+ // Scope interception to this bundled dependency, never to the shared host UI.
45
+ // Its tools/autocomplete/status APIs pass through; prompt ownership stays with
46
+ // Gentle Shell (or a genuine third-party editor). This also blocks stale restores.
47
+ const owned = new Set(["setEditorComponent", "setWorkingIndicator", "setWorkingMessage", "setWorkingVisible"]);
48
+ const delegated = new Proxy(api, {
49
+ get(target, key) {
50
+ if (key !== "on") return Reflect.get(target, key);
51
+ return (event: Parameters<ExtensionAPI["on"]>[0], handler: (event: unknown, ctx: ExtensionContext) => unknown) => {
52
+ api.on(event, ((event: unknown, ctx: ExtensionContext) => {
53
+ if (ctx.mode !== "tui") return handler(event, ctx);
54
+ const ui = new Proxy(ctx.ui, {
55
+ get(target, key) {
56
+ return owned.has(String(key)) ? () => {} : Reflect.get(target, key);
57
+ },
58
+ });
59
+ return handler(event, new Proxy(ctx, { get: (target, key) => key === "ui" ? ui : Reflect.get(target, key) }));
60
+ }) as never);
61
+ };
62
+ },
63
+ });
64
+ const result = await extension(delegated, deps);
65
+ // GentlePromptEditor already carries the live working state in its frame.
66
+ // Hide Pi's separate loader row to avoid repeating Thinking above the input;
67
+ // transcript thinking blocks remain untouched as historical reasoning markers.
68
+ api.on("session_start", (_event, ctx) => {
69
+ if (ctx.mode === "tui") ctx.ui.setWorkingVisible(false);
70
+ });
71
+ api.on("session_shutdown", (_event, ctx) => {
72
+ if (ctx.mode !== "tui") return;
73
+ ctx.ui.setWorkingVisible(true);
74
+ ctx.ui.setWorkingIndicator();
75
+ ctx.ui.setWorkingMessage();
76
+ });
77
+ return result;
29
78
  }
@@ -238,7 +238,7 @@ function shellTokens(command: string): ShellTokenization {
238
238
  function isAssignment(token: string): boolean {
239
239
  return /^[A-Za-z_][A-Za-z0-9_]*=/.test(token);
240
240
  }
241
- const SDD_ATTEMPT_VERBS = new Set(["acquire", "settle", "grant"]);
241
+ const SDD_ATTEMPT_VERBS = new Set(["grant"]);
242
242
 
243
243
  const REVIEW_DIRECT_OPERATIONS = new Set([
244
244
  "capabilities",
@@ -354,7 +354,6 @@ function gentleAiOperationPathFrom(tokens: string[]): string {
354
354
  if (tokens[0] === "sdd-attempt") {
355
355
  const verb = tokens[1] ?? "";
356
356
  if (!SDD_ATTEMPT_VERBS.has(verb)) return "sdd attempt";
357
- if (verb !== "grant") return `sdd attempt ${verb}`;
358
357
  const rootCount = authorizationRootCount(tokens);
359
358
  return rootCount > 0
360
359
  ? `sdd attempt grant · ${rootCount} root${rootCount === 1 ? "" : "s"}`
@@ -29,14 +29,14 @@ const BANNER_PALETTES: Record<BannerColor, { rose: [number, number, number]; lab
29
29
  };
30
30
 
31
31
  const TEXT_LOGO = [
32
- " ▄▄▄▀▀▀▀▀██ ▄▄▀▄▄ ▄▄█▀▀▀██ ▀▀█▄ ▄▄▄",
33
- " ▄▄█▀▀▒▒▒▒▒▄▄█▀▒ ▄██ ▄▄█▀█▄█▀▒▒ ▄█▀▀ ▒▒▒▄█▀▀▒ ▄██▒ ▄█▀▒▒▒",
34
- " ▄▄██▀▒▒▒▒▒▄▄▄▀▀▒▒▒▒ ▄▄▄ ▀▀▀▀██▀▀▀▀▀███▀█▄▀▀▒▒▒▒ ██▒▒▒▒▄▄█▀▒▒▒▒▄▄█▀▀▄██▀▒▒▒",
35
- " ▄██▀▒▒▒▒ ▒▒▄▄█ ▄▄▄▀██ ▄▄▄▀▀▀▄ ▄██▀▒▒▒▒▄██▀▀▀▒▄▄███ ▒▒ ▄███▄▄▄█▀▀▀▒▒▄██▀▒▒▒",
36
- " ██▀▒▒▒ ▄▄▄███▀▄██▀▀▀▄▄██▀▀▄█▀▄▄██▀▒▒▒▄▄██▀▒▒▄██▀▀▀▄▄▀▀▀▀▀▀▀▀▀ ▄█▀▀▒▒▒▒▒▒▒▒▒▄██▒▒▒▒",
37
- " ▀█▄▄▄▄▄▀▀▀█▄▄███▄▒▀▀▀▀▀▀▒▀▀▒▒▀▀▀▀▒██▄▄▀▀▀ ▀█▄▀▀▀ ▀▀▀▀▀▒▒▒▒▒▒▒▒▒▒▄██▀▒▒▒ ███▒▒",
38
- " ▒▄▄▄█▀▀▀█▄█▀▀▒▒▒▒ ▒▒▒▒▒▒ ▒▒ ▒▒▒▒ ▒▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒ ▀▀▀▒▒▒ ▒▒▒",
39
- " ▄▄▀▀ ▒▒▒▒▄██▀▒▒▒▒ ▒▒▒",
32
+ " ▄▄▄▀▀▀▀▀██ ▄▄▀▄▄ ▄▄█▀▀▀██ ▄▄▀██ ▄▄▀▄▄ ▄▄▀▄▄",
33
+ " ▄▄█▀▀▒▒▒▒▒▄▄█▀▒ ▄██ ▄▄█▀█▄█▀▒▒ ▄█▀▀▒▒▒▄█▀▒ ▄██▄█▀▒ ▄▄█▀█▄█▀▄▄█▀█▄█▀▒",
34
+ " ▄▄██▀▒▒▒▒▒▄▄▄▀▀▒▒▒▒ ▄▄▄ ▀▀▀▀██▀▀▀▀▀███▀█▄▀▀▒▒▒▒ ██▄▄▒▒▒▒▒▒▒ ▄██▀▀▒▒▒ ▄██▀█▄▀▀▄██▀█▄▀▀▒▒▒",
35
+ " ▄██▀▒▒▒▒ ▒▒▄▄█ ▄▄▄▀██ ▄▄▄▀▀▀▄ ▄██▀▒▒▒▒▄██▀▀▀▒▄▄███ ▀▀▀██▄▄ ▄██▀▒▄▄▀██ ▄▄▀▀██ ██▀▀▀▒▒▒██▀▀▀▒▒▒",
36
+ " ██▀▒▒▒ ▄▄▄███▀▄██▀▀▀▄▄██▀▀▄█▀▄▄██▀▒▒▒▄▄██▀▒▒▄██▀▀▀▄▄ ▄▄ ▀██▄▄██▀▄██▀██▒ ▄██▄▄▀▒ █▀▒▒▒ █▀▒▒▒",
37
+ " ▀█▄▄▄▄▄▀▀▀█▄▄███▄▒▀▀▀▀▀▀▒▀▀▒▒▀▀▀▀▒██▄▄▀▀▀ ▀█▄▀▀▀ ▀▀▀▀▀▒▒ ▀█▄▄▄▄█▀▀▒▒▀▀▒▒▀▀▒ ██▄▄▀▀█▄▄▄▄▀▀█▄▄▀▀▀▄▄▀█▄▄▀▀▀",
38
+ " ▒▄▄▄█▀▀▀█▄█▀▀▒▒▒▒ ▒▒▒▒▒▒ ▒▒ ▒▒▒▒ ▒▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒ ▒▀▀▀▀▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒",
39
+ " ▄▄▀▀ ▒▒▒▒▄██▀▒▒▒▒",
40
40
  " ▄█ ▒▒▒▄▄██▀▀▒▒▒▒",
41
41
  " ▀▀▀▀▀▀▒▒▒▒▒▒",
42
42
  " ▒▒▒▒▒▒",
@@ -183,7 +183,8 @@ function buildLetterSpans(bounds: Span, weights: number[]): Span[] {
183
183
  }
184
184
 
185
185
  const LOGO_BOUNDS = computeLogoBounds(TEXT_LOGO);
186
- const LETTER_WEIGHTS = [14, 10, 11, 10, 9, 11, 6, 13, 12]; // G E N T L E - P I
186
+ // Variable-width script regions, including the gap before Shell and shadows.
187
+ const LETTER_WEIGHTS = [22, 9, 8, 9, 8, 9, 10, 13, 8, 8, 13]; // G E N T L E S H E L L
187
188
  const LETTER_SPANS = buildLetterSpans(LOGO_BOUNDS, LETTER_WEIGHTS);
188
189
 
189
190
  function letterIndexAtX(x: number): number {
package/lib/agent-home.ts CHANGED
@@ -6,3 +6,11 @@ import { join } from "node:path";
6
6
  export function resolveGentlePiAgentHome(env: NodeJS.ProcessEnv = process.env): string {
7
7
  return env.GENTLE_PI_AGENT_HOME || env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
8
8
  }
9
+
10
+ // The Gentle AI config directory outside `~/.pi/agent`: everything the extension
11
+ // commands own lives here. It is defined once because the launch-time profile pin
12
+ // resolver reads the same profiles store the `/gentle:profiles` panel writes, and
13
+ // two spellings of the override would silently read two different stores.
14
+ export function gentlePiConfigHome(env: NodeJS.ProcessEnv = process.env): string {
15
+ return env.GENTLE_PI_CONFIG_HOME || join(homedir(), ".pi", "gentle-ai");
16
+ }