@oh-my-pi/pi-coding-agent 3.1.1337 → 3.4.1337
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/CHANGELOG.md +19 -0
- package/docs/extension-loading.md +2 -2
- package/docs/sdk.md +2 -2
- package/package.json +4 -4
- package/src/capability/rule.ts +4 -0
- package/src/core/agent-session.ts +92 -1
- package/src/core/sdk.ts +14 -0
- package/src/core/session-manager.ts +60 -4
- package/src/core/settings-manager.ts +68 -0
- package/src/core/ttsr.ts +211 -0
- package/src/discovery/builtin.ts +1 -0
- package/src/discovery/cline.ts +2 -0
- package/src/discovery/cursor.ts +2 -0
- package/src/discovery/windsurf.ts +3 -0
- package/src/modes/interactive/components/footer.ts +15 -1
- package/src/modes/interactive/components/settings-defs.ts +29 -0
- package/src/modes/interactive/components/ttsr-notification.ts +82 -0
- package/src/modes/interactive/interactive-mode.ts +161 -130
- package/src/modes/print-mode.ts +2 -2
package/src/core/ttsr.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time Traveling Stream Rules (TTSR) Manager
|
|
3
|
+
*
|
|
4
|
+
* Manages rules that get injected mid-stream when their trigger pattern matches
|
|
5
|
+
* the agent's output. When a match occurs, the stream is aborted, the rule is
|
|
6
|
+
* injected as a system reminder, and the request is retried.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Rule } from "../capability/rule";
|
|
10
|
+
import { logger } from "./logger";
|
|
11
|
+
import type { TtsrSettings } from "./settings-manager";
|
|
12
|
+
|
|
13
|
+
interface TtsrEntry {
|
|
14
|
+
rule: Rule;
|
|
15
|
+
regex: RegExp;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Tracks when a rule was last injected (for repeat-after-gap mode) */
|
|
19
|
+
interface InjectionRecord {
|
|
20
|
+
/** Message count when the rule was last injected */
|
|
21
|
+
lastInjectedAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TtsrManager {
|
|
25
|
+
/** Add a TTSR rule to be monitored */
|
|
26
|
+
addRule(rule: Rule): void;
|
|
27
|
+
|
|
28
|
+
/** Check if any uninjected TTSR matches the stream buffer. Returns matching rules. */
|
|
29
|
+
check(streamBuffer: string): Rule[];
|
|
30
|
+
|
|
31
|
+
/** Mark rules as injected (won't trigger again until conditions allow) */
|
|
32
|
+
markInjected(rules: Rule[]): void;
|
|
33
|
+
|
|
34
|
+
/** Get names of all injected rules (for persistence) */
|
|
35
|
+
getInjectedRuleNames(): string[];
|
|
36
|
+
|
|
37
|
+
/** Restore injected state from a list of rule names */
|
|
38
|
+
restoreInjected(ruleNames: string[]): void;
|
|
39
|
+
|
|
40
|
+
/** Reset stream buffer (called on new turn) */
|
|
41
|
+
resetBuffer(): void;
|
|
42
|
+
|
|
43
|
+
/** Get current stream buffer */
|
|
44
|
+
getBuffer(): string;
|
|
45
|
+
|
|
46
|
+
/** Append to stream buffer */
|
|
47
|
+
appendToBuffer(text: string): void;
|
|
48
|
+
|
|
49
|
+
/** Check if any TTSRs are registered */
|
|
50
|
+
hasRules(): boolean;
|
|
51
|
+
|
|
52
|
+
/** Increment message counter (call after each turn) */
|
|
53
|
+
incrementMessageCount(): void;
|
|
54
|
+
|
|
55
|
+
/** Get current message count */
|
|
56
|
+
getMessageCount(): number;
|
|
57
|
+
|
|
58
|
+
/** Get settings */
|
|
59
|
+
getSettings(): Required<TtsrSettings>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const DEFAULT_SETTINGS: Required<TtsrSettings> = {
|
|
63
|
+
enabled: true,
|
|
64
|
+
contextMode: "discard",
|
|
65
|
+
repeatMode: "once",
|
|
66
|
+
repeatGap: 10,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createTtsrManager(settings?: TtsrSettings): TtsrManager {
|
|
70
|
+
/** Resolved settings with defaults */
|
|
71
|
+
const resolvedSettings: Required<TtsrSettings> = {
|
|
72
|
+
...DEFAULT_SETTINGS,
|
|
73
|
+
...settings,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Map of rule name -> { rule, compiled regex } */
|
|
77
|
+
const rules = new Map<string, TtsrEntry>();
|
|
78
|
+
|
|
79
|
+
/** Map of rule name -> injection record */
|
|
80
|
+
const injectionRecords = new Map<string, InjectionRecord>();
|
|
81
|
+
|
|
82
|
+
/** Current stream buffer for pattern matching */
|
|
83
|
+
let buffer = "";
|
|
84
|
+
|
|
85
|
+
/** Message counter for tracking gap between injections */
|
|
86
|
+
let messageCount = 0;
|
|
87
|
+
|
|
88
|
+
/** Check if a rule can be triggered based on repeat settings */
|
|
89
|
+
function canTrigger(ruleName: string): boolean {
|
|
90
|
+
const record = injectionRecords.get(ruleName);
|
|
91
|
+
if (!record) {
|
|
92
|
+
// Never injected, can trigger
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (resolvedSettings.repeatMode === "once") {
|
|
97
|
+
// Once mode: never trigger again after first injection
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// After-gap mode: check if enough messages have passed
|
|
102
|
+
const gap = messageCount - record.lastInjectedAt;
|
|
103
|
+
return gap >= resolvedSettings.repeatGap;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
addRule(rule: Rule): void {
|
|
108
|
+
// Only add rules that have a TTSR trigger pattern
|
|
109
|
+
if (!rule.ttsrTrigger) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Skip if already registered
|
|
114
|
+
if (rules.has(rule.name)) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Compile the regex pattern
|
|
119
|
+
try {
|
|
120
|
+
const regex = new RegExp(rule.ttsrTrigger);
|
|
121
|
+
rules.set(rule.name, { rule, regex });
|
|
122
|
+
logger.debug("TTSR rule registered", {
|
|
123
|
+
ruleName: rule.name,
|
|
124
|
+
pattern: rule.ttsrTrigger,
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
logger.warn("TTSR rule has invalid regex pattern, skipping", {
|
|
128
|
+
ruleName: rule.name,
|
|
129
|
+
pattern: rule.ttsrTrigger,
|
|
130
|
+
error: err instanceof Error ? err.message : String(err),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
check(streamBuffer: string): Rule[] {
|
|
136
|
+
const matches: Rule[] = [];
|
|
137
|
+
|
|
138
|
+
for (const [name, entry] of rules) {
|
|
139
|
+
// Skip rules that can't trigger yet
|
|
140
|
+
if (!canTrigger(name)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Test the buffer against the rule's pattern
|
|
145
|
+
if (entry.regex.test(streamBuffer)) {
|
|
146
|
+
matches.push(entry.rule);
|
|
147
|
+
logger.debug("TTSR pattern matched", {
|
|
148
|
+
ruleName: name,
|
|
149
|
+
pattern: entry.rule.ttsrTrigger,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return matches;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
markInjected(rulesToMark: Rule[]): void {
|
|
158
|
+
for (const rule of rulesToMark) {
|
|
159
|
+
injectionRecords.set(rule.name, { lastInjectedAt: messageCount });
|
|
160
|
+
logger.debug("TTSR rule marked as injected", {
|
|
161
|
+
ruleName: rule.name,
|
|
162
|
+
messageCount,
|
|
163
|
+
repeatMode: resolvedSettings.repeatMode,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
getInjectedRuleNames(): string[] {
|
|
169
|
+
return Array.from(injectionRecords.keys());
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
restoreInjected(ruleNames: string[]): void {
|
|
173
|
+
// When restoring, we don't know the original message count, so use 0
|
|
174
|
+
// This means in "after-gap" mode, rules can trigger again after the gap
|
|
175
|
+
for (const name of ruleNames) {
|
|
176
|
+
injectionRecords.set(name, { lastInjectedAt: 0 });
|
|
177
|
+
}
|
|
178
|
+
if (ruleNames.length > 0) {
|
|
179
|
+
logger.debug("TTSR injected state restored", { ruleNames });
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
resetBuffer(): void {
|
|
184
|
+
buffer = "";
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
getBuffer(): string {
|
|
188
|
+
return buffer;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
appendToBuffer(text: string): void {
|
|
192
|
+
buffer += text;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
hasRules(): boolean {
|
|
196
|
+
return rules.size > 0;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
incrementMessageCount(): void {
|
|
200
|
+
messageCount++;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getMessageCount(): number {
|
|
204
|
+
return messageCount;
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
getSettings(): Required<TtsrSettings> {
|
|
208
|
+
return resolvedSettings;
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
package/src/discovery/builtin.ts
CHANGED
|
@@ -310,6 +310,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
310
310
|
globs: frontmatter.globs as string[] | undefined,
|
|
311
311
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
312
312
|
description: frontmatter.description as string | undefined,
|
|
313
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
313
314
|
_source: source,
|
|
314
315
|
};
|
|
315
316
|
},
|
package/src/discovery/cline.ts
CHANGED
|
@@ -52,6 +52,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
52
52
|
globs,
|
|
53
53
|
alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
|
|
54
54
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
55
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
55
56
|
_source: source,
|
|
56
57
|
};
|
|
57
58
|
},
|
|
@@ -85,6 +86,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
85
86
|
globs,
|
|
86
87
|
alwaysApply: typeof frontmatter.alwaysApply === "boolean" ? frontmatter.alwaysApply : undefined,
|
|
87
88
|
description: typeof frontmatter.description === "string" ? frontmatter.description : undefined,
|
|
89
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
88
90
|
_source: source,
|
|
89
91
|
});
|
|
90
92
|
}
|
package/src/discovery/cursor.ts
CHANGED
|
@@ -163,6 +163,7 @@ function transformMDCRule(
|
|
|
163
163
|
// Extract frontmatter fields
|
|
164
164
|
const description = typeof frontmatter.description === "string" ? frontmatter.description : undefined;
|
|
165
165
|
const alwaysApply = frontmatter.alwaysApply === true;
|
|
166
|
+
const ttsrTrigger = typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined;
|
|
166
167
|
|
|
167
168
|
// Parse globs (can be array or single string)
|
|
168
169
|
let globs: string[] | undefined;
|
|
@@ -182,6 +183,7 @@ function transformMDCRule(
|
|
|
182
183
|
description,
|
|
183
184
|
alwaysApply,
|
|
184
185
|
globs,
|
|
186
|
+
ttsrTrigger,
|
|
185
187
|
_source: source,
|
|
186
188
|
};
|
|
187
189
|
}
|
|
@@ -128,6 +128,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
128
128
|
globs,
|
|
129
129
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
130
130
|
description: frontmatter.description as string | undefined,
|
|
131
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
131
132
|
_source: createSourceMeta(PROVIDER_ID, userPath, "user"),
|
|
132
133
|
});
|
|
133
134
|
}
|
|
@@ -157,6 +158,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
157
158
|
globs,
|
|
158
159
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
159
160
|
description: frontmatter.description as string | undefined,
|
|
161
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
160
162
|
_source: source,
|
|
161
163
|
};
|
|
162
164
|
},
|
|
@@ -187,6 +189,7 @@ function loadRules(ctx: LoadContext): LoadResult<Rule> {
|
|
|
187
189
|
globs,
|
|
188
190
|
alwaysApply: frontmatter.alwaysApply as boolean | undefined,
|
|
189
191
|
description: frontmatter.description as string | undefined,
|
|
192
|
+
ttsrTrigger: typeof frontmatter.ttsr_trigger === "string" ? frontmatter.ttsr_trigger : undefined,
|
|
190
193
|
_source: createSourceMeta(PROVIDER_ID, legacyPath, "project"),
|
|
191
194
|
});
|
|
192
195
|
}
|
|
@@ -58,6 +58,10 @@ export class FooterComponent implements Component {
|
|
|
58
58
|
private autoCompactEnabled: boolean = true;
|
|
59
59
|
private hookStatuses: Map<string, string> = new Map();
|
|
60
60
|
|
|
61
|
+
// Git status caching (1s TTL to avoid excessive subprocess spawns)
|
|
62
|
+
private cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null;
|
|
63
|
+
private gitStatusLastFetch = 0;
|
|
64
|
+
|
|
61
65
|
constructor(session: AgentSession) {
|
|
62
66
|
this.session = session;
|
|
63
67
|
}
|
|
@@ -165,8 +169,14 @@ export class FooterComponent implements Component {
|
|
|
165
169
|
/**
|
|
166
170
|
* Get git status indicators (staged, unstaged, untracked counts).
|
|
167
171
|
* Returns null if not in a git repo.
|
|
172
|
+
* Cached for 1s to avoid excessive subprocess spawns.
|
|
168
173
|
*/
|
|
169
174
|
private getGitStatus(): { staged: number; unstaged: number; untracked: number } | null {
|
|
175
|
+
const now = Date.now();
|
|
176
|
+
if (now - this.gitStatusLastFetch < 1000) {
|
|
177
|
+
return this.cachedGitStatus;
|
|
178
|
+
}
|
|
179
|
+
|
|
170
180
|
try {
|
|
171
181
|
const output = execSync("git status --porcelain 2>/dev/null", {
|
|
172
182
|
encoding: "utf8",
|
|
@@ -200,8 +210,12 @@ export class FooterComponent implements Component {
|
|
|
200
210
|
}
|
|
201
211
|
}
|
|
202
212
|
|
|
203
|
-
|
|
213
|
+
this.cachedGitStatus = { staged, unstaged, untracked };
|
|
214
|
+
this.gitStatusLastFetch = now;
|
|
215
|
+
return this.cachedGitStatus;
|
|
204
216
|
} catch {
|
|
217
|
+
this.cachedGitStatus = null;
|
|
218
|
+
this.gitStatusLastFetch = now;
|
|
205
219
|
return null;
|
|
206
220
|
}
|
|
207
221
|
}
|
|
@@ -155,6 +155,35 @@ export const SETTINGS_DEFS: SettingDef[] = [
|
|
|
155
155
|
get: (sm) => sm.getEditFuzzyMatch(),
|
|
156
156
|
set: (sm, v) => sm.setEditFuzzyMatch(v),
|
|
157
157
|
},
|
|
158
|
+
{
|
|
159
|
+
id: "ttsrEnabled",
|
|
160
|
+
tab: "config",
|
|
161
|
+
type: "boolean",
|
|
162
|
+
label: "TTSR enabled",
|
|
163
|
+
description: "Time Traveling Stream Rules: interrupt agent when output matches rule patterns",
|
|
164
|
+
get: (sm) => sm.getTtsrEnabled(),
|
|
165
|
+
set: (sm, v) => sm.setTtsrEnabled(v),
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
id: "ttsrContextMode",
|
|
169
|
+
tab: "config",
|
|
170
|
+
type: "enum",
|
|
171
|
+
label: "TTSR context mode",
|
|
172
|
+
description: "What to do with partial output when TTSR triggers",
|
|
173
|
+
values: ["discard", "keep"],
|
|
174
|
+
get: (sm) => sm.getTtsrContextMode(),
|
|
175
|
+
set: (sm, v) => sm.setTtsrContextMode(v as "keep" | "discard"),
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: "ttsrRepeatMode",
|
|
179
|
+
tab: "config",
|
|
180
|
+
type: "enum",
|
|
181
|
+
label: "TTSR repeat mode",
|
|
182
|
+
description: "How rules can repeat: once per session or after a message gap",
|
|
183
|
+
values: ["once", "after-gap"],
|
|
184
|
+
get: (sm) => sm.getTtsrRepeatMode(),
|
|
185
|
+
set: (sm, v) => sm.setTtsrRepeatMode(v as "once" | "after-gap"),
|
|
186
|
+
},
|
|
158
187
|
{
|
|
159
188
|
id: "thinkingLevel",
|
|
160
189
|
tab: "config",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Box, Container, Spacer, Text } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import type { Rule } from "../../../capability/rule";
|
|
3
|
+
import { theme } from "../theme/theme";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Component that renders a TTSR (Time Traveling Stream Rules) notification.
|
|
7
|
+
* Shows when a rule violation is detected and the stream is being rewound.
|
|
8
|
+
*/
|
|
9
|
+
export class TtsrNotificationComponent extends Container {
|
|
10
|
+
private rules: Rule[];
|
|
11
|
+
private box: Box;
|
|
12
|
+
private _expanded = false;
|
|
13
|
+
|
|
14
|
+
constructor(rules: Rule[]) {
|
|
15
|
+
super();
|
|
16
|
+
this.rules = rules;
|
|
17
|
+
|
|
18
|
+
this.addChild(new Spacer(1));
|
|
19
|
+
|
|
20
|
+
// Use inverse warning color for yellow background effect
|
|
21
|
+
this.box = new Box(1, 1, (t) => theme.inverse(theme.fg("warning", t)));
|
|
22
|
+
this.addChild(this.box);
|
|
23
|
+
|
|
24
|
+
this.rebuild();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setExpanded(expanded: boolean): void {
|
|
28
|
+
if (this._expanded !== expanded) {
|
|
29
|
+
this._expanded = expanded;
|
|
30
|
+
this.rebuild();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isExpanded(): boolean {
|
|
35
|
+
return this._expanded;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private rebuild(): void {
|
|
39
|
+
this.box.clear();
|
|
40
|
+
|
|
41
|
+
// Build header: ⚠ Injecting <bold>rule-name</bold> ↩
|
|
42
|
+
const ruleNames = this.rules.map((r) => theme.bold(r.name)).join(", ");
|
|
43
|
+
const label = this.rules.length === 1 ? "rule" : "rules";
|
|
44
|
+
const header = `\u26A0 Injecting ${label}: ${ruleNames}`;
|
|
45
|
+
|
|
46
|
+
// Create header with rewind icon on the right
|
|
47
|
+
const rewindIcon = "\u21A9"; // ↩
|
|
48
|
+
|
|
49
|
+
this.box.addChild(new Text(`${header} ${rewindIcon}`, 0, 0));
|
|
50
|
+
|
|
51
|
+
// Show description(s) - italic and truncated
|
|
52
|
+
for (const rule of this.rules) {
|
|
53
|
+
const desc = rule.description || rule.content;
|
|
54
|
+
if (desc) {
|
|
55
|
+
this.box.addChild(new Spacer(1));
|
|
56
|
+
|
|
57
|
+
let displayText = desc.trim();
|
|
58
|
+
if (!this._expanded) {
|
|
59
|
+
// Truncate to first 2 lines
|
|
60
|
+
const lines = displayText.split("\n");
|
|
61
|
+
if (lines.length > 2) {
|
|
62
|
+
displayText = `${lines.slice(0, 2).join("\n")}...`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Use italic for subtle distinction (fg colors conflict with inverse)
|
|
67
|
+
this.box.addChild(new Text(theme.italic(displayText), 0, 0));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Show expand hint if collapsed and there's more content
|
|
72
|
+
if (!this._expanded) {
|
|
73
|
+
const hasMoreContent = this.rules.some((r) => {
|
|
74
|
+
const desc = r.description || r.content;
|
|
75
|
+
return desc && desc.split("\n").length > 2;
|
|
76
|
+
});
|
|
77
|
+
if (hasMoreContent) {
|
|
78
|
+
this.box.addChild(new Text(theme.italic(" (ctrl+o to expand)"), 0, 0));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|