pi-cliproxyapi 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -16
- package/index.ts +3 -3
- package/package.json +1 -1
- package/src/apply.ts +85 -10
- package/src/commands.ts +12 -170
- package/src/fetch-models.ts +2 -5
- package/src/log.ts +17 -4
- package/src/ui-frame.ts +118 -0
- package/src/ui-hub/hub.ts +264 -0
- package/src/ui-hub/index.ts +50 -0
- package/src/ui-hub/shell.ts +119 -0
- package/src/ui-hub/types.ts +16 -0
- package/src/ui-hub/view-diagnostics.ts +108 -0
- package/src/ui-hub/view-models.ts +515 -0
- package/src/ui-hub/view-usage.ts +131 -0
- package/src/ui-picker/catalog.ts +52 -0
- package/src/ui-picker/mutate.ts +167 -0
- package/src/ui-picker/prompt-confirm.ts +71 -0
- package/src/ui-picker/prompt-name.ts +90 -0
- package/src/ui-picker/providers.ts +68 -0
- package/src/ui-picker/render-text.ts +39 -0
- package/src/ui-picker/rows.ts +151 -0
- package/src/ui-picker/types.ts +56 -0
- package/src/ui-setup.ts +21 -48
- package/src/ui-usage.ts +1 -1
- package/src/ui-overlay.ts +0 -292
- package/src/ui-picker.ts +0 -842
package/src/ui-setup.ts
CHANGED
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
getKeybindings,
|
|
15
15
|
Input,
|
|
16
16
|
matchesKey,
|
|
17
|
-
visibleWidth,
|
|
18
17
|
} from "@earendil-works/pi-tui";
|
|
19
18
|
|
|
20
19
|
import {
|
|
@@ -23,6 +22,7 @@ import {
|
|
|
23
22
|
resolveConfigValue,
|
|
24
23
|
saveConfig,
|
|
25
24
|
} from "./config.ts";
|
|
25
|
+
import { frame, frameInner } from "./ui-frame.ts";
|
|
26
26
|
|
|
27
27
|
interface Theme {
|
|
28
28
|
fg(name: string, s: string): string;
|
|
@@ -66,7 +66,7 @@ const STEPS: WizardStep[] = [
|
|
|
66
66
|
},
|
|
67
67
|
{
|
|
68
68
|
label: "usageKey",
|
|
69
|
-
hint: "Optional X-Plugin-Key for /api/usage. Leave blank to skip
|
|
69
|
+
hint: "Optional X-Plugin-Key for /api/usage. Leave blank to skip the Usage tab",
|
|
70
70
|
required: false,
|
|
71
71
|
},
|
|
72
72
|
];
|
|
@@ -104,13 +104,9 @@ export async function runSetup(
|
|
|
104
104
|
const prefill = forceAll
|
|
105
105
|
? (values[step.label] ?? step.initialValue ?? "")
|
|
106
106
|
: (values[step.label] ?? "");
|
|
107
|
-
// Skip already-filled non-empty fields when called for first-run setup
|
|
108
|
-
// (we still want to ask for missing pieces, but not re-prompt for
|
|
109
|
-
// what's already saved).
|
|
110
107
|
if (!forceAll && prefill) {
|
|
111
108
|
continue;
|
|
112
109
|
}
|
|
113
|
-
|
|
114
110
|
const result = await promptStep(
|
|
115
111
|
ctx,
|
|
116
112
|
step,
|
|
@@ -123,7 +119,7 @@ export async function runSetup(
|
|
|
123
119
|
const trimmed = result.trim();
|
|
124
120
|
if (!trimmed) {
|
|
125
121
|
if (step.required) {
|
|
126
|
-
ctx.ui.notify(`${step.label} is required
|
|
122
|
+
ctx.ui.notify(`${step.label} is required \u2014 aborted`, "warning");
|
|
127
123
|
cancelled = true;
|
|
128
124
|
break;
|
|
129
125
|
}
|
|
@@ -153,10 +149,6 @@ export async function runSetup(
|
|
|
153
149
|
return true;
|
|
154
150
|
}
|
|
155
151
|
|
|
156
|
-
/**
|
|
157
|
-
* If the user typed a bare ~/path or /abs/path, wrap it in `!cat <path>`
|
|
158
|
-
* so resolveConfigValue executes it. Otherwise return as-is.
|
|
159
|
-
*/
|
|
160
152
|
function normalizeValue(raw: string): string {
|
|
161
153
|
if (raw.startsWith("!") || raw.startsWith("$")) return raw;
|
|
162
154
|
if (raw.startsWith("~/")) {
|
|
@@ -189,10 +181,7 @@ async function promptStep(
|
|
|
189
181
|
prefill,
|
|
190
182
|
done,
|
|
191
183
|
),
|
|
192
|
-
{
|
|
193
|
-
overlay: true,
|
|
194
|
-
overlayOptions: { width: 100, maxHeight: "60%" },
|
|
195
|
-
},
|
|
184
|
+
{ overlay: true, overlayOptions: { width: 100, maxHeight: "60%" } },
|
|
196
185
|
);
|
|
197
186
|
}
|
|
198
187
|
|
|
@@ -220,8 +209,6 @@ function buildStepOverlay(
|
|
|
220
209
|
}
|
|
221
210
|
if (step.validate) {
|
|
222
211
|
const trimmed = raw.trim();
|
|
223
|
-
// Only validate the literal form. Indirect ("!", "$") values are
|
|
224
|
-
// validated at apply time, not here.
|
|
225
212
|
if (trimmed && !trimmed.startsWith("!") && !trimmed.startsWith("$")) {
|
|
226
213
|
const err = step.validate(trimmed);
|
|
227
214
|
if (err) {
|
|
@@ -239,38 +226,30 @@ function buildStepOverlay(
|
|
|
239
226
|
|
|
240
227
|
return {
|
|
241
228
|
render(width: number): string[] {
|
|
242
|
-
const inner =
|
|
243
|
-
const top = theme.fg(
|
|
244
|
-
"borderAccent",
|
|
245
|
-
`\u256d\u2500 ${theme.bold(theme.fg("accent", `setup: ${step.label}`))} ${"\u2500".repeat(Math.max(0, inner - visibleWidth(`setup: ${step.label}`) - 4))}\u256e`,
|
|
246
|
-
);
|
|
247
|
-
const hintLine = pad(theme.fg("dim", step.hint), inner - 2);
|
|
229
|
+
const inner = frameInner(width);
|
|
248
230
|
const inputLines = input.render(inner - 4);
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
);
|
|
266
|
-
return out;
|
|
231
|
+
const lines: string[] = [
|
|
232
|
+
"",
|
|
233
|
+
` ${theme.fg("dim", step.hint)}`,
|
|
234
|
+
"",
|
|
235
|
+
...inputLines.map((ln) => ` ${theme.fg("accent", `\u276f ${ln}`)}`),
|
|
236
|
+
"",
|
|
237
|
+
error
|
|
238
|
+
? ` ${theme.fg("error", `! ${error}`)}`
|
|
239
|
+
: ` ${theme.fg("dim", "enter = save \u00b7 esc = cancel")}`,
|
|
240
|
+
];
|
|
241
|
+
return frame(theme, {
|
|
242
|
+
width,
|
|
243
|
+
title: ` setup: ${step.label} `,
|
|
244
|
+
lines,
|
|
245
|
+
footer: { hint: " enter = save \u00b7 esc = cancel " },
|
|
246
|
+
});
|
|
267
247
|
},
|
|
268
248
|
invalidate(): void {
|
|
269
249
|
input.invalidate();
|
|
270
250
|
},
|
|
271
251
|
handleInput(data: string): void {
|
|
272
252
|
const kb = getKeybindings();
|
|
273
|
-
// Plain Esc cancels even if Input doesn't dispatch it.
|
|
274
253
|
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
275
254
|
done(undefined);
|
|
276
255
|
return;
|
|
@@ -281,9 +260,3 @@ function buildStepOverlay(
|
|
|
281
260
|
},
|
|
282
261
|
};
|
|
283
262
|
}
|
|
284
|
-
|
|
285
|
-
function pad(s: string, width: number): string {
|
|
286
|
-
const w = visibleWidth(s);
|
|
287
|
-
if (w >= width) return s;
|
|
288
|
-
return s + " ".repeat(width - w);
|
|
289
|
-
}
|
package/src/ui-usage.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Renders /api/usage as multi-line ANSI text for
|
|
1
|
+
// Renders /api/usage as multi-line ANSI text for the hub Usage tab.
|
|
2
2
|
//
|
|
3
3
|
// Lines are wrapped pre-render so they never reflow inside the overlay box —
|
|
4
4
|
// long errors get truncated with an ellipsis on the same line. Progress bars
|
package/src/ui-overlay.ts
DELETED
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
// Read-only scrollable overlay used by /cliproxy-list, /cliproxy-usage, /cliproxy-doctor.
|
|
2
|
-
//
|
|
3
|
-
// Pure presentational — never touches the agent session or context. The
|
|
4
|
-
// caller may supply boolean "toggles" (one keystroke each) that re-render the
|
|
5
|
-
// body when flipped. The overlay shell handles scrolling and dismissal.
|
|
6
|
-
//
|
|
7
|
-
// Built-in keys:
|
|
8
|
-
// ↑ / k scroll up one line (held = repeat via Kitty kbd protocol)
|
|
9
|
-
// ↓ / j scroll down one line
|
|
10
|
-
// PageUp / b scroll up one page
|
|
11
|
-
// PageDown / f scroll down one page (also Space)
|
|
12
|
-
// Home / g jump to top
|
|
13
|
-
// End / G jump to bottom
|
|
14
|
-
// Esc / q / Enter / Ctrl+C close
|
|
15
|
-
//
|
|
16
|
-
// Plus any caller-defined toggles.
|
|
17
|
-
|
|
18
|
-
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import {
|
|
20
|
-
type Component,
|
|
21
|
-
getKeybindings,
|
|
22
|
-
matchesKey,
|
|
23
|
-
visibleWidth,
|
|
24
|
-
} from "@earendil-works/pi-tui";
|
|
25
|
-
|
|
26
|
-
interface Theme {
|
|
27
|
-
fg(name: string, s: string): string;
|
|
28
|
-
bold(s: string): string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface OverlayTui {
|
|
32
|
-
requestRender(): void;
|
|
33
|
-
rows?: number;
|
|
34
|
-
cols?: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface OverlayToggle {
|
|
38
|
-
/** Single key id passed to matchesKey() (e.g. "d", "v"). */
|
|
39
|
-
key: string;
|
|
40
|
-
/** Short hint shown in the footer (e.g. "d disabled"). */
|
|
41
|
-
hint: string;
|
|
42
|
-
/** Initial state. Defaults to false. */
|
|
43
|
-
initial?: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export interface OverlayOptions {
|
|
47
|
-
title: string;
|
|
48
|
-
/** Re-rendered every time a toggle flips. Receives current toggle state. */
|
|
49
|
-
render: (state: Record<string, boolean>) => string;
|
|
50
|
-
toggles?: OverlayToggle[];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
interface BuildProps extends OverlayOptions {
|
|
54
|
-
done: (value: void) => void;
|
|
55
|
-
theme: Theme;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function buildOverlay(
|
|
59
|
-
tui: OverlayTui,
|
|
60
|
-
props: BuildProps,
|
|
61
|
-
): Component & { handleInput(data: string): void } {
|
|
62
|
-
const state: Record<string, boolean> = {};
|
|
63
|
-
for (const t of props.toggles ?? []) state[t.key] = t.initial ?? false;
|
|
64
|
-
let lines: string[] = props.render(state).split("\n");
|
|
65
|
-
let offset = 0;
|
|
66
|
-
let lastRenderHeight = 20;
|
|
67
|
-
let lastRenderWidth = 80;
|
|
68
|
-
|
|
69
|
-
const t = props.theme;
|
|
70
|
-
|
|
71
|
-
const recompute = (): void => {
|
|
72
|
-
lines = props.render(state).split("\n");
|
|
73
|
-
const visible = Math.max(1, lastRenderHeight - 2);
|
|
74
|
-
const max = Math.max(0, lines.length - visible);
|
|
75
|
-
if (offset > max) offset = max;
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
const renderFrame = (width: number, height: number): string[] => {
|
|
79
|
-
const inner = Math.max(10, width - 2);
|
|
80
|
-
const visible = Math.max(1, height - 2);
|
|
81
|
-
const total = lines.length;
|
|
82
|
-
const maxOffset = Math.max(0, total - visible);
|
|
83
|
-
if (offset > maxOffset) offset = maxOffset;
|
|
84
|
-
const slice = lines.slice(offset, offset + visible);
|
|
85
|
-
while (slice.length < visible) slice.push("");
|
|
86
|
-
const pct =
|
|
87
|
-
total > visible
|
|
88
|
-
? Math.min(100, Math.round(((offset + visible) / total) * 100))
|
|
89
|
-
: 100;
|
|
90
|
-
const titleBar = formatTitleBar(t, props.title, inner);
|
|
91
|
-
const footerBar = formatFooterBar(t, {
|
|
92
|
-
from: offset + 1,
|
|
93
|
-
to: Math.min(total, offset + visible),
|
|
94
|
-
total,
|
|
95
|
-
pct,
|
|
96
|
-
width: inner,
|
|
97
|
-
toggles: (props.toggles ?? []).map((tg) => ({
|
|
98
|
-
hint: tg.hint,
|
|
99
|
-
active: state[tg.key] === true,
|
|
100
|
-
})),
|
|
101
|
-
});
|
|
102
|
-
const sideL = t.fg("borderAccent", "│");
|
|
103
|
-
const sideR = t.fg("borderAccent", "│");
|
|
104
|
-
const out: string[] = [titleBar];
|
|
105
|
-
for (const ln of slice) {
|
|
106
|
-
out.push(`${sideL} ${padRight(ln, inner - 2)} ${sideR}`);
|
|
107
|
-
}
|
|
108
|
-
out.push(footerBar);
|
|
109
|
-
return out;
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
return {
|
|
113
|
-
render(width: number): string[] {
|
|
114
|
-
lastRenderWidth = width;
|
|
115
|
-
lastRenderHeight = Math.max(
|
|
116
|
-
10,
|
|
117
|
-
Math.min(lines.length + 2, (tui.rows ?? 40) - 6),
|
|
118
|
-
);
|
|
119
|
-
return renderFrame(lastRenderWidth, lastRenderHeight);
|
|
120
|
-
},
|
|
121
|
-
invalidate(): void {
|
|
122
|
-
/* stateless render */
|
|
123
|
-
},
|
|
124
|
-
handleInput(data: string): void {
|
|
125
|
-
const visible = Math.max(1, lastRenderHeight - 2);
|
|
126
|
-
const max = Math.max(0, lines.length - visible);
|
|
127
|
-
const kb = getKeybindings();
|
|
128
|
-
|
|
129
|
-
// Caller toggles run first so they shadow letter-key scroll bindings.
|
|
130
|
-
for (const tg of props.toggles ?? []) {
|
|
131
|
-
if (matchesKey(data, tg.key as never)) {
|
|
132
|
-
state[tg.key] = !state[tg.key];
|
|
133
|
-
recompute();
|
|
134
|
-
tui.requestRender();
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
kb.matches(data, "tui.select.cancel") ||
|
|
141
|
-
matchesKey(data, "q") ||
|
|
142
|
-
matchesKey(data, "shift+q") ||
|
|
143
|
-
matchesKey(data, "enter") ||
|
|
144
|
-
matchesKey(data, "return")
|
|
145
|
-
) {
|
|
146
|
-
props.done();
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
let next = offset;
|
|
150
|
-
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
151
|
-
next = Math.max(0, offset - 1);
|
|
152
|
-
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
153
|
-
next = Math.min(max, offset + 1);
|
|
154
|
-
} else if (matchesKey(data, "pageUp") || matchesKey(data, "b")) {
|
|
155
|
-
next = Math.max(0, offset - visible);
|
|
156
|
-
} else if (
|
|
157
|
-
matchesKey(data, "pageDown") ||
|
|
158
|
-
matchesKey(data, "f") ||
|
|
159
|
-
matchesKey(data, "space")
|
|
160
|
-
) {
|
|
161
|
-
next = Math.min(max, offset + visible);
|
|
162
|
-
} else if (matchesKey(data, "home") || matchesKey(data, "g")) {
|
|
163
|
-
next = 0;
|
|
164
|
-
} else if (matchesKey(data, "end") || matchesKey(data, "shift+g")) {
|
|
165
|
-
next = max;
|
|
166
|
-
} else {
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
if (next !== offset) {
|
|
170
|
-
offset = next;
|
|
171
|
-
tui.requestRender();
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// --------------------------------------------------------------------------- chrome helpers
|
|
178
|
-
|
|
179
|
-
function formatTitleBar(theme: Theme, title: string, inner: number): string {
|
|
180
|
-
const label = ` ${title} `;
|
|
181
|
-
const leftSep = "╭─";
|
|
182
|
-
const rightFill = inner - visibleWidth(label) - visibleWidth(leftSep) - 2;
|
|
183
|
-
const fill = "─".repeat(Math.max(0, rightFill));
|
|
184
|
-
return theme.fg(
|
|
185
|
-
"borderAccent",
|
|
186
|
-
`${leftSep}${theme.bold(theme.fg("accent", label))}${theme.fg("borderAccent", fill)}╮`,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function formatFooterBar(
|
|
191
|
-
theme: Theme,
|
|
192
|
-
opts: {
|
|
193
|
-
from: number;
|
|
194
|
-
to: number;
|
|
195
|
-
total: number;
|
|
196
|
-
pct: number;
|
|
197
|
-
width: number;
|
|
198
|
-
toggles: Array<{ hint: string; active: boolean }>;
|
|
199
|
-
},
|
|
200
|
-
): string {
|
|
201
|
-
const hintBase = "↑↓ · pgUp/pgDn · g/G";
|
|
202
|
-
const togglesText = opts.toggles
|
|
203
|
-
.map((tg) =>
|
|
204
|
-
tg.active
|
|
205
|
-
? theme.fg("success", `[${tg.hint}]`)
|
|
206
|
-
: theme.fg("dim", tg.hint),
|
|
207
|
-
)
|
|
208
|
-
.join(" · ");
|
|
209
|
-
const left = ` ${hintBase}${togglesText ? " " + togglesText : ""} `;
|
|
210
|
-
const right = ` ${opts.from}–${opts.to} of ${opts.total} ${opts.pct}% `;
|
|
211
|
-
const leftSep = "╰─";
|
|
212
|
-
const rightSep = "╯";
|
|
213
|
-
const used =
|
|
214
|
-
visibleWidth(leftSep) +
|
|
215
|
-
visibleWidth(rightSep) +
|
|
216
|
-
visibleWidth(left) +
|
|
217
|
-
visibleWidth(right);
|
|
218
|
-
const filler = "─".repeat(Math.max(0, opts.width - used));
|
|
219
|
-
return theme.fg(
|
|
220
|
-
"borderAccent",
|
|
221
|
-
`${leftSep}${theme.fg("dim", left)}${filler}${theme.fg("muted", right)}${rightSep}`,
|
|
222
|
-
);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function padRight(s: string, width: number): string {
|
|
226
|
-
const w = visibleWidth(s);
|
|
227
|
-
if (w >= width) return s;
|
|
228
|
-
return s + " ".repeat(width - w);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// --------------------------------------------------------------------------- public API
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Show a scrollable overlay with optional toggles.
|
|
235
|
-
*
|
|
236
|
-
* If you don't need toggles, pass a string for the body and we'll wrap it.
|
|
237
|
-
*/
|
|
238
|
-
export async function showOverlay(
|
|
239
|
-
ctx: ExtensionCommandContext,
|
|
240
|
-
title: string,
|
|
241
|
-
bodyOrOptions: string | Omit<OverlayOptions, "title">,
|
|
242
|
-
): Promise<void> {
|
|
243
|
-
const opts: OverlayOptions =
|
|
244
|
-
typeof bodyOrOptions === "string"
|
|
245
|
-
? { title, render: () => bodyOrOptions }
|
|
246
|
-
: { title, ...bodyOrOptions };
|
|
247
|
-
|
|
248
|
-
if (!ctx.hasUI) {
|
|
249
|
-
ctx.ui.notify(`${title}\n\n${opts.render({})}`, "info");
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Probe widest line across all toggle combinations so the overlay doesn't
|
|
254
|
-
// resize as the user flips switches. With N toggles we check 2^N states,
|
|
255
|
-
// but in practice we only ever pass 0-2 toggles.
|
|
256
|
-
const probedWidth = probeMaxWidth(opts);
|
|
257
|
-
const desiredCols = Math.min(140, Math.max(72, probedWidth + 6));
|
|
258
|
-
|
|
259
|
-
await ctx.ui.custom<void>(
|
|
260
|
-
(tui, theme, _kb, done) =>
|
|
261
|
-
buildOverlay(tui as unknown as OverlayTui, {
|
|
262
|
-
...opts,
|
|
263
|
-
done,
|
|
264
|
-
theme: theme as unknown as Theme,
|
|
265
|
-
}),
|
|
266
|
-
{
|
|
267
|
-
overlay: true,
|
|
268
|
-
overlayOptions: {
|
|
269
|
-
width: desiredCols,
|
|
270
|
-
maxHeight: "80%",
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
function probeMaxWidth(opts: OverlayOptions): number {
|
|
277
|
-
const toggles = opts.toggles ?? [];
|
|
278
|
-
const combos = 1 << toggles.length;
|
|
279
|
-
let max = 0;
|
|
280
|
-
for (let i = 0; i < combos; i++) {
|
|
281
|
-
const state: Record<string, boolean> = {};
|
|
282
|
-
toggles.forEach((tg, j) => {
|
|
283
|
-
state[tg.key] = (i & (1 << j)) !== 0;
|
|
284
|
-
});
|
|
285
|
-
const body = opts.render(state);
|
|
286
|
-
for (const ln of body.split("\n")) {
|
|
287
|
-
const w = visibleWidth(ln);
|
|
288
|
-
if (w > max) max = w;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
return max;
|
|
292
|
-
}
|