decorated-pi 0.7.1 → 0.7.3

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/ui/ask.ts CHANGED
@@ -28,10 +28,6 @@ export interface AskQuestion {
28
28
  question: string;
29
29
  options?: string[];
30
30
  default?: string;
31
- /** When true on single/multi, an "Other" row is appended after the
32
- * regular options. Picking it switches the row into an inline text
33
- * input — lets the user type a custom answer not in the preset list. */
34
- allowCustom?: boolean;
35
31
  }
36
32
 
37
33
  export interface AskAnswer {
@@ -41,7 +37,7 @@ export interface AskAnswer {
41
37
 
42
38
  interface QuestionState {
43
39
  value: string | string[];
44
- cursor: number; // option cursor; for allowCustom, opts.length = "Other" row
40
+ cursor: number; // option cursor; opts.length maps to the "Other" row
45
41
  /** Typed text when the cursor sits on the "Other" row (single or multi). */
46
42
  customText: string;
47
43
  /** Multi only: whether "Other" is currently toggled into the selection. */
@@ -62,10 +58,39 @@ function parseDefault(type: AskQuestionType, options: string[] | undefined, defa
62
58
  return { value: selected, cursor: 0, customText: "", customSelected: false };
63
59
  }
64
60
 
65
- /** For single/multi with allowCustom, returns the effective option count
61
+ /** Returns true for single/multi choice questions, which always get an
62
+ * "Other" row for entering a custom answer not in the preset list. */
63
+ function hasOtherRow(q: AskQuestion): boolean {
64
+ return q.type === "single" || q.type === "multi";
65
+ }
66
+
67
+ /** For single/multi questions, returns the effective option count
66
68
  * (regular options plus the "Other" row). */
67
69
  function effectiveOptionCount(q: AskQuestion): number {
68
- return (q.options?.length ?? 0) + (q.allowCustom ? 1 : 0);
70
+ return (q.options?.length ?? 0) + (hasOtherRow(q) ? 1 : 0);
71
+ }
72
+
73
+ const BRACKETED_PASTE_START = "\x1b[200~";
74
+ const BRACKETED_PASTE_END = "\x1b[201~";
75
+
76
+ /** Extract content from a bracketed-paste sequence. Returns undefined if the
77
+ * data is not a complete paste. Any trailing input after the end marker is
78
+ * returned as `remaining` so it can be processed recursively. */
79
+ function extractBracketedPaste(data: string): { content: string; remaining: string } | undefined {
80
+ if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
81
+ const afterStart = data.slice(BRACKETED_PASTE_START.length);
82
+ const endIndex = afterStart.indexOf(BRACKETED_PASTE_END);
83
+ if (endIndex === -1) return undefined;
84
+ return {
85
+ content: afterStart.slice(0, endIndex),
86
+ remaining: afterStart.slice(endIndex + BRACKETED_PASTE_END.length),
87
+ };
88
+ }
89
+
90
+ /** Normalize pasted text for a single-line input: strip line breaks and
91
+ * expand tabs to spaces (matches pi-tui's built-in Input behavior). */
92
+ function cleanPastedText(text: string): string {
93
+ return text.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
69
94
  }
70
95
 
71
96
  class DynamicBorder implements Component {
@@ -133,31 +158,31 @@ export class AskComponent extends Container {
133
158
  const state = this.currentState();
134
159
  if (q.type === "text") return (state.value as string).trim() !== "";
135
160
  if (q.type === "single") {
136
- if (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
161
+ if (state.cursor === (q.options?.length ?? 0)) {
137
162
  return state.customText.trim() !== "";
138
163
  }
139
164
  return (state.value as string) !== "";
140
165
  }
141
166
  // multi: valid if any regular option is selected, or "Other" is
142
167
  // toggled AND has non-empty custom text.
143
- if (q.allowCustom && state.customSelected) {
168
+ if (state.customSelected) {
144
169
  return state.customText.trim() !== "" || (state.value as string[]).length > 0;
145
170
  }
146
171
  return (state.value as string[]).length > 0;
147
172
  }
148
173
 
149
174
  /** The answer that will actually be submitted for this question. Differs
150
- * from state.value when allowCustom is in play and "Other" is chosen. */
175
+ * from state.value when the cursor is on the "Other" row. */
151
176
  private committedValue(q: AskQuestion, state: QuestionState): string | string[] {
152
177
  if (q.type === "single") {
153
- if (q.allowCustom && state.cursor === (q.options?.length ?? 0)) {
178
+ if (state.cursor === (q.options?.length ?? 0)) {
154
179
  return state.customText.trim();
155
180
  }
156
181
  return state.value as string;
157
182
  }
158
183
  if (q.type === "multi") {
159
184
  const base = state.value as string[];
160
- if (q.allowCustom && state.customSelected && state.customText.trim() !== "") {
185
+ if (state.customSelected && state.customText.trim() !== "") {
161
186
  return [...base, state.customText.trim()];
162
187
  }
163
188
  return base;
@@ -209,6 +234,35 @@ export class AskComponent extends Container {
209
234
  handleInput(data: string): void {
210
235
  const kb = getKeybindings();
211
236
 
237
+ // ── Bracketed paste ─────────────────────────────────────────────
238
+ // Terminals that support bracketed paste mode wrap pasted content in
239
+ // \x1b[200~ ... \x1b[201~. Without handling this, the leading ESC is
240
+ // rejected as a control character and nothing is inserted.
241
+ const paste = extractBracketedPaste(data);
242
+ if (paste) {
243
+ if (!this.summaryMode) {
244
+ const text = cleanPastedText(paste.content);
245
+ const q = this.currentQuestion();
246
+ const state = this.currentState();
247
+ if (q.type === "text") {
248
+ state.value = (state.value as string) + text;
249
+ } else if (hasOtherRow(q)) {
250
+ const opts = q.options ?? [];
251
+ if (state.cursor === opts.length) {
252
+ state.customText += text;
253
+ if (q.type === "multi") {
254
+ state.customSelected = true;
255
+ }
256
+ }
257
+ }
258
+ this.renderView();
259
+ }
260
+ if (paste.remaining) {
261
+ this.handleInput(paste.remaining);
262
+ }
263
+ return;
264
+ }
265
+
212
266
  // ── Summary mode ────────────────────────────────────────────────
213
267
  if (this.summaryMode) {
214
268
  if (data === "\r" || data === "\n" || kb.matches(data, "tui.select.confirm")) {
@@ -264,7 +318,7 @@ export class AskComponent extends Container {
264
318
  } else if (q.type === "single" && q.options) {
265
319
  const opts = q.options;
266
320
  const totalLen = effectiveOptionCount(q);
267
- const onCustomRow = q.allowCustom === true && state.cursor === opts.length;
321
+ const onCustomRow = state.cursor === opts.length;
268
322
 
269
323
  if (kb.matches(data, "tui.select.up")) {
270
324
  const next = (state.cursor - 1 + totalLen) % totalLen;
@@ -290,7 +344,7 @@ export class AskComponent extends Container {
290
344
  } else if (q.type === "multi" && q.options) {
291
345
  const opts = q.options;
292
346
  const totalLen = effectiveOptionCount(q);
293
- const onCustomRow = q.allowCustom === true && state.cursor === opts.length;
347
+ const onCustomRow = state.cursor === opts.length;
294
348
 
295
349
  if (kb.matches(data, "tui.select.up")) {
296
350
  state.cursor = (state.cursor - 1 + totalLen) % totalLen;
@@ -367,7 +421,7 @@ export class AskComponent extends Container {
367
421
  const opts = q.options;
368
422
  const totalLen = effectiveOptionCount(q);
369
423
  for (let j = 0; j < totalLen; j++) {
370
- const isCustomRow = q.allowCustom === true && j === opts.length;
424
+ const isCustomRow = j === opts.length;
371
425
  const optLabel = isCustomRow ? "Other" : opts[j];
372
426
  const selected = isCustomRow ? false : optLabel === state.value;
373
427
  const atCursor = j === state.cursor;
@@ -389,7 +443,7 @@ export class AskComponent extends Container {
389
443
  const opts = q.options;
390
444
  const totalLen = effectiveOptionCount(q);
391
445
  for (let j = 0; j < totalLen; j++) {
392
- const isCustomRow = q.allowCustom === true && j === opts.length;
446
+ const isCustomRow = j === opts.length;
393
447
  const optLabel = isCustomRow ? "Other" : opts[j];
394
448
  const inValue = !isCustomRow && (state.value as string[]).includes(optLabel);
395
449
  const customOn = isCustomRow && state.customSelected;
@@ -413,13 +467,9 @@ export class AskComponent extends Container {
413
467
  if (q.type === "text") {
414
468
  hint = "Enter next · Esc cancel";
415
469
  } else if (q.type === "single") {
416
- hint = q.allowCustom
417
- ? "↑↓ move · Enter next · Esc cancel"
418
- : "↑↓ move · Enter next · Esc cancel";
470
+ hint = "↑↓ move · Enter next · Esc cancel";
419
471
  } else {
420
- hint = q.allowCustom
421
- ? "↑↓ move · Space toggle · Enter next · Esc cancel"
422
- : "↑↓ move · Space toggle · Enter next · Esc cancel";
472
+ hint = "↑↓ move · Space toggle · Enter next · Esc cancel";
423
473
  }
424
474
  this.hintComponent.setText(this.theme.fg("dim", " " + hint));
425
475
  }
@@ -6,9 +6,11 @@
6
6
  * category so the user can toggle each one.
7
7
  */
8
8
 
9
- import type { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
9
+ import type { Theme as PiTheme, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
10
10
  import { Container, SettingsList, type TUI, type SettingsListTheme, type SettingItem, type Component } from "@earendil-works/pi-tui";
11
- import { getAllModuleSettings, setModuleEnabled, type ModuleSettings } from "../settings.js";
11
+ import { getAllModuleSettings, setModuleEnabled, type ModuleSettings, getDependencyPath, setDependencyPath, isDontBother, setDontBother, getDependencyView, listDependencyViewNames } from "../settings.js";
12
+ import { listLspBinaryNames } from "../tools/lsp/servers.js";
13
+ import { listMcpBinaryNames } from "../tools/mcp/config.js";
12
14
 
13
15
  type ModuleName =
14
16
  | "patchOverrideEdit"
@@ -48,7 +50,7 @@ const MODULE_DESCS: Record<ModuleName, string> = {
48
50
  usage: "/usage command for token stats",
49
51
  };
50
52
 
51
- type CategoryId = "tools" | "hooks" | "commands";
53
+ type CategoryId = "commands" | "hooks" | "tools";
52
54
 
53
55
  interface CategoryDef {
54
56
  label: string;
@@ -57,23 +59,27 @@ interface CategoryDef {
57
59
  }
58
60
 
59
61
  const CATEGORIES: Record<CategoryId, CategoryDef> = {
60
- tools: {
61
- label: "Tools",
62
- description: "LLM-callable tools",
63
- modules: ["patchOverrideEdit", "ask", "lsp", "mcp"],
62
+ commands: {
63
+ label: "Commands",
64
+ description: "Slash commands",
65
+ modules: ["atOverride", "retry", "usage"],
64
66
  },
65
67
  hooks: {
66
68
  label: "Hooks",
67
69
  description: "Agent-loop event handlers",
68
- modules: ["secretRedaction", "rtk", "wakatime"],
70
+ modules: ["rtk", "secretRedaction", "wakatime"],
69
71
  },
70
- commands: {
71
- label: "Commands",
72
- description: "Slash commands",
73
- modules: ["atOverride", "retry", "usage"],
72
+ tools: {
73
+ label: "Tools",
74
+ description: "LLM-callable tools",
75
+ modules: ["ask", "lsp", "mcp", "patchOverrideEdit"],
74
76
  },
75
77
  };
76
78
 
79
+ // Hard-coded display order, alphabetized by visible label. Dependencies is
80
+ // inserted between Commands and Hooks in ModuleSettingsComponent below.
81
+ const CATEGORY_ORDER: CategoryId[] = ["commands", "hooks", "tools"];
82
+
77
83
  class DynamicBorder implements Component {
78
84
  private colorFn: (str: string) => string;
79
85
  constructor(theme: PiTheme) { this.colorFn = (str: string) => theme.fg("border", str); }
@@ -135,8 +141,6 @@ class CategorySubmenu extends Container {
135
141
  (id: string, newValue: string) => {
136
142
  setModuleEnabled(id, newValue === "on");
137
143
  this.list.updateValue(id, newValue);
138
- // Stay open so the user can toggle multiple items; the parent
139
- // summary updates when the submenu is closed with Esc.
140
144
  },
141
145
  () => done(summaryFor(getAllModuleSettings(), category.modules)),
142
146
  );
@@ -152,14 +156,132 @@ class CategorySubmenu extends Container {
152
156
  }
153
157
  }
154
158
 
159
+ /** Submenu for configuring binary path overrides. Each row is a binary
160
+ * that decorated-pi looks up at startup; Enter opens an input dialog
161
+ * where the user can type an absolute path (or clear it). */
162
+ function dependencyDisplayValue(name: string): string {
163
+ const view = getDependencyView(name);
164
+ if (view.path) return view.path;
165
+ if (view.resolvedPath) return view.resolvedPath;
166
+ if (view.resolvedState === undefined) return view.dontBother ? "(not checked, silenced)" : "(not checked)";
167
+ return view.dontBother ? "(not found, silenced)" : "(not found)";
168
+ }
169
+
170
+ class DependencyBinarySubmenu extends Container {
171
+ private list: SettingsList;
172
+ private name: string;
173
+ private ui: ExtensionUIContext;
174
+
175
+ constructor(name: string, theme: PiTheme, ui: ExtensionUIContext, done: (summary?: string) => void) {
176
+ super();
177
+ this.name = name;
178
+ this.ui = ui;
179
+
180
+ const items: SettingItem[] = [
181
+ {
182
+ id: "path",
183
+ label: "Path override",
184
+ description: "Enter to edit; empty to clear override",
185
+ currentValue: dependencyDisplayValue(name),
186
+ values: ["edit"],
187
+ },
188
+ {
189
+ id: "dontBother",
190
+ label: "dontBother",
191
+ description: "Silence missing-dependency notification for this binary",
192
+ currentValue: isDontBother(name) ? "on" : "off",
193
+ values: ["off", "on"],
194
+ },
195
+ ];
196
+
197
+ this.list = new SettingsList(
198
+ items,
199
+ 10,
200
+ getSettingsListTheme(theme),
201
+ (id: string, newValue: string) => {
202
+ if (id === "dontBother") {
203
+ setDontBother(this.name, newValue === "on");
204
+ this.list.updateValue("dontBother", newValue);
205
+ this.list.updateValue("path", dependencyDisplayValue(this.name));
206
+ return;
207
+ }
208
+ this.list.updateValue("path", dependencyDisplayValue(this.name));
209
+ void this.promptForPath();
210
+ },
211
+ () => done(dependencyDisplayValue(this.name)),
212
+ );
213
+ this.addChild(this.list);
214
+ }
215
+
216
+ handleInput(data: string) {
217
+ this.list.handleInput(data);
218
+ }
219
+
220
+ private async promptForPath(): Promise<void> {
221
+ const current = getDependencyPath(this.name) ?? "";
222
+ const input = await this.ui.input(
223
+ `Path for ${this.name} (empty to clear)`,
224
+ current || `/absolute/path/to/${this.name}`,
225
+ );
226
+ if (input === undefined) return;
227
+ setDependencyPath(this.name, input.trim() === "" ? null : input.trim());
228
+ this.list.updateValue("path", dependencyDisplayValue(this.name));
229
+ }
230
+
231
+ render(width: number): string[] {
232
+ return this.list.render(width);
233
+ }
234
+ }
235
+
236
+ class DependenciesSubmenu extends Container {
237
+ private list: SettingsList;
238
+ private binaryNames: string[];
239
+
240
+ constructor(theme: PiTheme, ui: ExtensionUIContext, done: (summary?: string) => void) {
241
+ super();
242
+ // Builtins we know about plus entries already present in config/shadow.
243
+ this.binaryNames = listDependencyViewNames([
244
+ "rtk",
245
+ "wakatime-cli",
246
+ ...listLspBinaryNames(),
247
+ ...listMcpBinaryNames(),
248
+ ]);
249
+
250
+ const items: SettingItem[] = this.binaryNames.map((name) => ({
251
+ id: name,
252
+ label: name,
253
+ description: "Enter to configure path override and dontBother",
254
+ currentValue: dependencyDisplayValue(name),
255
+ submenu: (_currentValue, submenuDone) => new DependencyBinarySubmenu(name, theme, ui, submenuDone),
256
+ }));
257
+
258
+ this.list = new SettingsList(
259
+ items,
260
+ 10,
261
+ getSettingsListTheme(theme),
262
+ () => {},
263
+ () => done(summaryForDependencies()),
264
+ );
265
+ this.addChild(this.list);
266
+ }
267
+
268
+ handleInput(data: string) {
269
+ this.list.handleInput(data);
270
+ }
271
+
272
+ render(width: number): string[] {
273
+ return this.list.render(width);
274
+ }
275
+ }
276
+
155
277
  export class ModuleSettingsComponent extends Container {
156
278
  private settingsList: SettingsList;
157
279
 
158
- constructor(tui: TUI, theme: PiTheme, onDone: () => void) {
280
+ constructor(tui: TUI, theme: PiTheme, ui: ExtensionUIContext, onDone: () => void) {
159
281
  super();
160
282
  const modules = getAllModuleSettings();
161
283
 
162
- const categoryItems: SettingItem[] = (Object.keys(CATEGORIES) as CategoryId[]).map((id) => ({
284
+ const categoryItems: SettingItem[] = CATEGORY_ORDER.map((id) => ({
163
285
  id,
164
286
  label: CATEGORIES[id].label,
165
287
  description: CATEGORIES[id].description,
@@ -167,6 +289,17 @@ export class ModuleSettingsComponent extends Container {
167
289
  submenu: (_currentValue, done) => new CategorySubmenu(id, theme, done),
168
290
  }));
169
291
 
292
+ // Dependencies is a separate top-level category — it doesn't fit
293
+ // ModuleSettings' on/off toggle model. Insert it alphabetically between
294
+ // Commands and Hooks.
295
+ categoryItems.splice(1, 0, {
296
+ id: "dependencies",
297
+ label: "Dependencies",
298
+ description: "Override binary paths (rtk, wakatime-cli, LSP/MCP servers)",
299
+ currentValue: summaryForDependencies(),
300
+ submenu: (_currentValue, done) => new DependenciesSubmenu(theme, ui, done),
301
+ });
302
+
170
303
  this.addChild(new DynamicBorder(theme));
171
304
 
172
305
  this.settingsList = new SettingsList(
@@ -186,3 +319,20 @@ export class ModuleSettingsComponent extends Container {
186
319
  this.settingsList.handleInput(data);
187
320
  }
188
321
  }
322
+
323
+ /** Count how many binaries have an explicit override. */
324
+ function summaryForDependencies(): string {
325
+ // Builtins we know about: rtk, wakatime-cli, LSP servers, MCP servers.
326
+ const known = listDependencyViewNames([
327
+ "rtk",
328
+ "wakatime-cli",
329
+ ...listLspBinaryNames(),
330
+ ...listMcpBinaryNames(),
331
+ ]);
332
+ const overridden = known.filter((n) => getDependencyPath(n) !== null).length;
333
+ const silenced = known.filter((n) => isDontBother(n)).length;
334
+ const parts: string[] = [];
335
+ if (overridden) parts.push(`${overridden} overridden`);
336
+ if (silenced) parts.push(`${silenced} silenced`);
337
+ return parts.length ? parts.join(", ") : "default";
338
+ }
package/utils/which.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Locate a binary by name. Pure Node — no shell required.
3
+ *
4
+ * Why no shell: the previous implementation called `bash -c "command -v X"`,
5
+ * which depended on `command` being a builtin in the user's shell. That's
6
+ * true for bash/zsh/sh/ash/dash, but not for nushell/fish/pwsh — so a user
7
+ * with `SHELL=/usr/bin/nu` saw the whole extension fail to start with
8
+ * "External command failed" errors from the `command` builtin lookup.
9
+ *
10
+ * Walking $PATH with fs.accessSync(X_OK) is universal:
11
+ * - No shell dependency
12
+ * - No builtin dependency
13
+ * - Works on minimal Linux (alpine, busybox, distroless)
14
+ * - Works regardless of $SHELL
15
+ * - No execFileSync overhead per call
16
+ *
17
+ * `extendPath` lets callers inject extra search locations (user-configured
18
+ * override paths, module-specific fallback dirs like ~/.wakatime, project
19
+ * node_modules/.bin). Each entry can be a file path (checked directly) or
20
+ * a directory (searched for `name` inside). extendPath entries are tried
21
+ * before $PATH, so callers control priority by ordering.
22
+ */
23
+ import { accessSync, constants, statSync } from "node:fs";
24
+ import { delimiter, resolve } from "node:path";
25
+ import { execFileSync } from "node:child_process";
26
+ import { homedir } from "node:os";
27
+
28
+ export interface WhichOptions {
29
+ /** Extra locations to search before $PATH. Each entry can be a file
30
+ * path (e.g. "/custom/bin/rtk") or a directory (e.g. "~/.wakatime").
31
+ * Tried in array order; first executable match wins. */
32
+ extendPath?: string[];
33
+ }
34
+
35
+ export function which(name: string, opts?: WhichOptions): string | null {
36
+ if (!name) return null;
37
+
38
+ // Absolute or relative path in name: check the file directly.
39
+ if (name.includes("/") || name.includes("\\")) {
40
+ try {
41
+ accessSync(name, constants.X_OK);
42
+ return resolve(name);
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ if (process.platform === "win32") {
49
+ // Windows: `where` is a built-in command that handles PATHEXT
50
+ // (the per-user list of executable extensions — typically
51
+ // .COM;.EXE;.BAT;.CMD;.VBS;.JS;.WSC;.MSC;.PS1) and PATH lookup.
52
+ // We don't replicate that in pure Node; we defer to it.
53
+ try {
54
+ const out = execFileSync("where", [name], { encoding: "utf-8" }).trim();
55
+ return out.split(/\r?\n/)[0] || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ // Build the search list: extendPath entries first, then $PATH dirs.
62
+ // extendPath entries can be files (checked directly) or directories
63
+ // (searched for `name` inside). We stat each to tell them apart.
64
+ const home = homedir();
65
+ const expandHome = (d: string): string => {
66
+ if (d === "~") return home;
67
+ if (d.startsWith("~/") || d.startsWith("~\\")) return home + d.slice(1);
68
+ return d;
69
+ };
70
+
71
+ const tryCandidate = (candidate: string): string | null => {
72
+ try {
73
+ accessSync(candidate, constants.X_OK);
74
+ return resolve(candidate);
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ // 1. extendPath (caller-injected): override paths + module fallbacks.
81
+ for (const entry of opts?.extendPath ?? []) {
82
+ const expanded = expandHome(entry);
83
+ let isDir = false;
84
+ try {
85
+ isDir = statSync(expanded).isDirectory();
86
+ } catch {
87
+ // Doesn't exist or not accessible — skip.
88
+ continue;
89
+ }
90
+ const candidate = isDir ? resolve(expanded, name) : expanded;
91
+ const found = tryCandidate(candidate);
92
+ if (found) return found;
93
+ }
94
+
95
+ // 2. $PATH walk — mirrors the standard `which(1)` command's behavior:
96
+ // exact match in each directory, no recursion.
97
+ //
98
+ // `~` expansion: bash expands `~/bin` when sourced from .bashrc via
99
+ // `export PATH=~/bin:$PATH`, but not when PATH is set in launchd,
100
+ // systemd Environment=, Docker ENV, or GUI app launchers. We expand
101
+ // it here so we don't regress on those paths.
102
+ const dirs = (process.env.PATH || "").split(delimiter).map(expandHome);
103
+ for (const dir of dirs) {
104
+ if (!dir) continue;
105
+ const found = tryCandidate(resolve(dir, name));
106
+ if (found) return found;
107
+ }
108
+ return null;
109
+ }