@narumitw/pi-btw 0.20.0 → 0.28.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/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./btw.js";
@@ -0,0 +1,247 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Message,
6
+ Model,
7
+ SimpleStreamOptions,
8
+ UserMessage,
9
+ } from "@earendil-works/pi-ai";
10
+
11
+ export const BTW_THINKING_LEVELS = [
12
+ "off",
13
+ "minimal",
14
+ "low",
15
+ "medium",
16
+ "high",
17
+ "xhigh",
18
+ "max",
19
+ ] as const;
20
+
21
+ export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
22
+
23
+ export interface SideQuestionAuth {
24
+ apiKey?: string;
25
+ headers?: Record<string, string>;
26
+ env?: Record<string, string>;
27
+ }
28
+
29
+ export type CompleteSimpleFunction = <TApi extends Api>(
30
+ model: Model<TApi>,
31
+ context: Context,
32
+ options?: SimpleStreamOptions,
33
+ ) => Promise<AssistantMessage>;
34
+
35
+ type ModuleImporter = (moduleId: string) => Promise<unknown>;
36
+
37
+ function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
38
+ return (
39
+ typeof value === "object" &&
40
+ value !== null &&
41
+ typeof Reflect.get(value, "completeSimple") === "function"
42
+ );
43
+ }
44
+
45
+ export async function loadCompleteSimple(
46
+ importModule: ModuleImporter = (moduleId) => import(moduleId),
47
+ ): Promise<CompleteSimpleFunction> {
48
+ let importError: unknown;
49
+ for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
50
+ try {
51
+ const module = await importModule(moduleId);
52
+ if (hasCompleteSimple(module)) return module.completeSimple;
53
+ } catch (error: unknown) {
54
+ importError = error;
55
+ }
56
+ }
57
+
58
+ throw new Error("@earendil-works/pi-ai does not export completeSimple", {
59
+ cause: importError,
60
+ });
61
+ }
62
+
63
+ const defaultCompleteSimple = await loadCompleteSimple();
64
+
65
+ export type SideThreadTurn =
66
+ | {
67
+ kind: "answered";
68
+ question: string;
69
+ answer: string;
70
+ response: AssistantMessage;
71
+ }
72
+ | {
73
+ kind: "error";
74
+ question: string;
75
+ answer: string;
76
+ };
77
+
78
+ export interface SideThread {
79
+ conversationContext: string;
80
+ turns: SideThreadTurn[];
81
+ }
82
+
83
+ export function createSideThread(conversationContext: string): SideThread {
84
+ return { conversationContext, turns: [] };
85
+ }
86
+
87
+ export function buildSideThreadMessages(thread: SideThread, question: string): Message[] {
88
+ const answeredTurns = thread.turns.filter(
89
+ (turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
90
+ );
91
+ const messages: Message[] = [];
92
+
93
+ if (answeredTurns.length === 0) {
94
+ messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
95
+ return messages;
96
+ }
97
+
98
+ const [first, ...rest] = answeredTurns;
99
+ messages.push(
100
+ createUserMessage(buildUserPrompt(first.question, thread.conversationContext)),
101
+ first.response,
102
+ );
103
+ for (const turn of rest) {
104
+ messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
105
+ }
106
+ messages.push(createUserMessage(buildFollowUpPrompt(question)));
107
+ return messages;
108
+ }
109
+
110
+ export interface CompleteSideThreadTurnOptions {
111
+ thread: SideThread;
112
+ model: Model<Api>;
113
+ question: string;
114
+ thinkingLevel: BtwThinkingLevel;
115
+ auth: SideQuestionAuth;
116
+ signal?: AbortSignal;
117
+ completeSimple?: CompleteSimpleFunction;
118
+ }
119
+
120
+ export type CompleteSideThreadTurnResult =
121
+ | { kind: "answered"; response: AssistantMessage; answer: string }
122
+ | { kind: "aborted" }
123
+ | { kind: "error"; message: string };
124
+
125
+ export async function completeSideThreadTurn({
126
+ thread,
127
+ model,
128
+ question,
129
+ thinkingLevel,
130
+ auth,
131
+ signal,
132
+ completeSimple = defaultCompleteSimple,
133
+ }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
134
+ if (signal?.aborted) return { kind: "aborted" };
135
+ let response: AssistantMessage;
136
+ try {
137
+ response = await completeSimple(
138
+ model,
139
+ { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
140
+ buildStreamOptions(auth, thinkingLevel, signal),
141
+ );
142
+ } catch (error: unknown) {
143
+ if (signal?.aborted) return { kind: "aborted" };
144
+ return { kind: "error", message: formatError(error) };
145
+ }
146
+
147
+ if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
148
+ if (response.stopReason === "error") {
149
+ return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
150
+ }
151
+
152
+ const answer = extractAssistantText(response) || "No response received.";
153
+ thread.turns.push({ kind: "answered", question, answer, response });
154
+ return { kind: "answered", response, answer };
155
+ }
156
+
157
+ export interface CompleteSideQuestionOptions {
158
+ model: Model<Api>;
159
+ question: string;
160
+ conversationContext: string;
161
+ thinkingLevel: BtwThinkingLevel;
162
+ auth: SideQuestionAuth;
163
+ signal?: AbortSignal;
164
+ completeSimple?: CompleteSimpleFunction;
165
+ }
166
+
167
+ export async function completeSideQuestion({
168
+ model,
169
+ question,
170
+ conversationContext,
171
+ thinkingLevel,
172
+ auth,
173
+ signal,
174
+ completeSimple = defaultCompleteSimple,
175
+ }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
176
+ return completeSimple(
177
+ model,
178
+ {
179
+ systemPrompt: SYSTEM_PROMPT,
180
+ messages: [createUserMessage(buildUserPrompt(question, conversationContext))],
181
+ },
182
+ buildStreamOptions(auth, thinkingLevel, signal),
183
+ );
184
+ }
185
+
186
+ export function extractAssistantText(response: AssistantMessage): string {
187
+ return response.content
188
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
189
+ .map((content) => content.text)
190
+ .join("\n")
191
+ .trim();
192
+ }
193
+
194
+ export function buildUserPrompt(question: string, conversationContext: string): string {
195
+ return [
196
+ "Answer this side question without modifying the main conversation.",
197
+ "",
198
+ "<side_question>",
199
+ question,
200
+ "</side_question>",
201
+ "",
202
+ "<conversation_context>",
203
+ conversationContext || "No prior conversation context was available.",
204
+ "</conversation_context>",
205
+ ].join("\n");
206
+ }
207
+
208
+ export function buildFollowUpPrompt(question: string): string {
209
+ return [
210
+ "Continue the same side conversation.",
211
+ "",
212
+ "<side_question>",
213
+ question,
214
+ "</side_question>",
215
+ ].join("\n");
216
+ }
217
+
218
+ function createUserMessage(text: string): UserMessage {
219
+ return {
220
+ role: "user",
221
+ content: [{ type: "text", text }],
222
+ timestamp: Date.now(),
223
+ };
224
+ }
225
+
226
+ function buildStreamOptions(
227
+ auth: SideQuestionAuth,
228
+ thinkingLevel: BtwThinkingLevel,
229
+ signal?: AbortSignal,
230
+ ): SimpleStreamOptions {
231
+ const options: SimpleStreamOptions = {
232
+ apiKey: auth.apiKey,
233
+ headers: auth.headers,
234
+ env: auth.env,
235
+ signal,
236
+ };
237
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
238
+ return options;
239
+ }
240
+
241
+ function formatError(error: unknown): string {
242
+ return error instanceof Error ? error.message : String(error);
243
+ }
244
+
245
+ const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
246
+
247
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
@@ -0,0 +1,378 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import {
3
+ AssistantMessageComponent,
4
+ getMarkdownTheme,
5
+ type Theme,
6
+ UserMessageComponent,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ type Component,
10
+ CURSOR_MARKER,
11
+ Editor,
12
+ type EditorTheme,
13
+ Key,
14
+ Loader,
15
+ Markdown,
16
+ matchesKey,
17
+ type TUI,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
21
+ import type { SideThreadTurn } from "./side-thread.js";
22
+
23
+ const TRANSCRIPT_CHROME_LINES = 2;
24
+ const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
25
+ // Pi renders a spacer above the custom component and a two-line built-in footer below it.
26
+ const RESERVED_APP_LINES = 3;
27
+
28
+ export type TranscriptPagerAction = { kind: "submit"; question: string } | { kind: "close" };
29
+
30
+ export class BtwTranscriptPager implements Component {
31
+ private readonly transcriptComponents: Component[];
32
+ private readonly editor: Editor;
33
+ private scrollOffset = 0;
34
+ private lastContentLineCount = 0;
35
+ private lastViewportHeight = 1;
36
+ private followBottom: boolean;
37
+ private warning: string | undefined;
38
+ private finished = false;
39
+ private isFocused = false;
40
+
41
+ constructor(
42
+ private readonly tui: TUI,
43
+ private readonly theme: Theme,
44
+ turns: readonly SideThreadTurn[],
45
+ private readonly onAction: (action: TranscriptPagerAction) => void,
46
+ options: { startAtBottom?: boolean } = {},
47
+ ) {
48
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
49
+ this.followBottom = options.startAtBottom ?? false;
50
+ const editorTheme: EditorTheme = {
51
+ borderColor: (text) => this.theme.fg("accent", text),
52
+ selectList: {
53
+ selectedPrefix: (text) => this.theme.fg("accent", text),
54
+ selectedText: (text) => this.theme.fg("accent", text),
55
+ description: (text) => this.theme.fg("muted", text),
56
+ scrollInfo: (text) => this.theme.fg("dim", text),
57
+ noMatch: (text) => this.theme.fg("warning", text),
58
+ },
59
+ };
60
+ this.editor = new Editor(this.tui, editorTheme);
61
+ this.editor.onChange = () => {
62
+ this.warning = undefined;
63
+ };
64
+ this.editor.onSubmit = (text) => {
65
+ const question = text.trim();
66
+ if (!question) {
67
+ this.warning = "Question cannot be empty";
68
+ return;
69
+ }
70
+ this.finished = true;
71
+ this.onAction({ kind: "submit", question });
72
+ };
73
+ }
74
+
75
+ get focused(): boolean {
76
+ return this.isFocused;
77
+ }
78
+
79
+ set focused(value: boolean) {
80
+ this.isFocused = value;
81
+ this.editor.focused = value;
82
+ }
83
+
84
+ render(width: number): string[] {
85
+ const safeWidth = Math.max(1, width);
86
+ const editorLines = this.editor.render(safeWidth);
87
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
88
+ const viewportHeight = Math.max(
89
+ 0,
90
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
91
+ );
92
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
93
+ this.lastContentLineCount = contentLines.length;
94
+ this.lastViewportHeight = viewportHeight;
95
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
96
+ this.clampScrollOffset();
97
+
98
+ return fitComposerLayout(
99
+ renderSideThreadHeader(safeWidth, this.theme),
100
+ contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
101
+ this.renderFooter(safeWidth),
102
+ editorLines,
103
+ availableRows,
104
+ );
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ if (this.finished) return;
109
+ if (matchesKey(data, Key.ctrl("c"))) {
110
+ this.finished = true;
111
+ this.onAction({ kind: "close" });
112
+ return;
113
+ }
114
+ if (matchesKey(data, Key.pageUp)) {
115
+ const previousOffset = this.scrollOffset;
116
+ this.scrollBy(-this.lastViewportHeight);
117
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
118
+ this.tui.requestRender();
119
+ return;
120
+ }
121
+ if (matchesKey(data, Key.pageDown)) {
122
+ this.scrollBy(this.lastViewportHeight);
123
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
124
+ this.tui.requestRender();
125
+ return;
126
+ }
127
+ this.editor.handleInput(data);
128
+ if (!this.finished) this.tui.requestRender();
129
+ }
130
+
131
+ invalidate(): void {
132
+ for (const component of this.transcriptComponents) component.invalidate();
133
+ this.editor.invalidate();
134
+ }
135
+
136
+ private renderFooter(width: number): string {
137
+ if (this.warning) {
138
+ const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
139
+ return truncateToWidth(this.theme.fg("warning", warning), width);
140
+ }
141
+ const scrollable = this.getMaxScrollOffset() > 0;
142
+ const fullBase = "btw • Enter send • Ctrl+C exit";
143
+ const compactBase = "btw • Enter • Ctrl+C";
144
+ let hints = visibleWidth(fullBase) <= width ? fullBase : compactBase;
145
+ if (scrollable) {
146
+ const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
147
+ const compactHistory = " • PgUp/PgDn";
148
+ if (visibleWidth(`${hints}${history}`) <= width) {
149
+ hints += history;
150
+ } else if (visibleWidth(`${hints}${compactHistory}`) <= width) {
151
+ hints += compactHistory;
152
+ } else if (visibleWidth(`${compactBase}${compactHistory}`) <= width) {
153
+ hints = `${compactBase}${compactHistory}`;
154
+ }
155
+ }
156
+ return truncateToWidth(this.theme.fg("muted", hints), width);
157
+ }
158
+
159
+ private scrollBy(delta: number): void {
160
+ this.scrollOffset += delta;
161
+ this.clampScrollOffset();
162
+ }
163
+
164
+ private clampScrollOffset(): void {
165
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
166
+ }
167
+
168
+ private getMaxScrollOffset(): number {
169
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
170
+ }
171
+ }
172
+
173
+ export class BtwAnsweringView implements Component {
174
+ private readonly transcriptComponents: Component[];
175
+ private readonly loader: Loader;
176
+ private readonly controller = new AbortController();
177
+ private scrollOffset = 0;
178
+ private lastContentLineCount = 0;
179
+ private lastViewportHeight = 1;
180
+ private followBottom = true;
181
+ private finished = false;
182
+
183
+ constructor(
184
+ private readonly tui: TUI,
185
+ private readonly theme: Theme,
186
+ turns: readonly SideThreadTurn[],
187
+ pendingQuestion: string,
188
+ private readonly onCancel: () => void,
189
+ ) {
190
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
191
+ this.loader = new Loader(
192
+ this.tui,
193
+ (text) => this.theme.fg("accent", text),
194
+ (text) => this.theme.fg("muted", text),
195
+ "Answering…",
196
+ );
197
+ }
198
+
199
+ get signal(): AbortSignal {
200
+ return this.controller.signal;
201
+ }
202
+
203
+ render(width: number): string[] {
204
+ const safeWidth = Math.max(1, width);
205
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
206
+ const viewportHeight = Math.max(0, availableRows - TRANSCRIPT_CHROME_LINES);
207
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
208
+ this.lastContentLineCount = contentLines.length;
209
+ this.lastViewportHeight = viewportHeight;
210
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
211
+ this.clampScrollOffset();
212
+ const cancelHint = safeWidth < 28 ? "Ctrl+C" : "Ctrl+C cancel";
213
+ const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
214
+ const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
215
+ const lines = [
216
+ renderSideThreadHeader(safeWidth, this.theme),
217
+ ...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
218
+ truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
219
+ ];
220
+ return fitWithFixedHeader(lines, availableRows);
221
+ }
222
+
223
+ handleInput(data: string): void {
224
+ if (this.finished) return;
225
+ if (matchesKey(data, Key.ctrl("c"))) {
226
+ this.finished = true;
227
+ this.loader.stop();
228
+ this.controller.abort();
229
+ this.onCancel();
230
+ return;
231
+ }
232
+ if (matchesKey(data, Key.pageUp)) {
233
+ const previousOffset = this.scrollOffset;
234
+ this.scrollBy(-this.lastViewportHeight);
235
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
236
+ this.tui.requestRender();
237
+ } else if (matchesKey(data, Key.pageDown)) {
238
+ this.scrollBy(this.lastViewportHeight);
239
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
240
+ this.tui.requestRender();
241
+ }
242
+ }
243
+
244
+ invalidate(): void {
245
+ for (const component of this.transcriptComponents) component.invalidate();
246
+ this.loader.invalidate();
247
+ }
248
+
249
+ finish(): void {
250
+ this.finished = true;
251
+ this.loader.stop();
252
+ }
253
+
254
+ dispose(): void {
255
+ this.finish();
256
+ this.controller.abort();
257
+ }
258
+
259
+ private scrollBy(delta: number): void {
260
+ this.scrollOffset += delta;
261
+ this.clampScrollOffset();
262
+ }
263
+
264
+ private clampScrollOffset(): void {
265
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
266
+ }
267
+
268
+ private getMaxScrollOffset(): number {
269
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
270
+ }
271
+ }
272
+
273
+ export function formatSideTranscript(turns: readonly SideThreadTurn[]): string {
274
+ return turns
275
+ .map((turn) => {
276
+ const question = escapeTerminalControls(turn.question);
277
+ const rawAnswer = escapeTerminalControls(turn.answer);
278
+ const answer = turn.kind === "error" ? `Error: ${rawAnswer}` : rawAnswer;
279
+ return `${question}\n\n${answer}`;
280
+ })
281
+ .join("\n\n");
282
+ }
283
+
284
+ function buildTranscriptComponents(
285
+ turns: readonly SideThreadTurn[],
286
+ theme: Theme,
287
+ pendingQuestion?: string,
288
+ ): Component[] {
289
+ const components = turns.flatMap((turn): Component[] => {
290
+ const question = new UserMessageComponent(
291
+ escapeTerminalControls(turn.question),
292
+ getMarkdownTheme(),
293
+ 1,
294
+ );
295
+ if (turn.kind === "error") {
296
+ const error = new Markdown(
297
+ `Error: ${escapeTerminalControls(turn.answer)}`,
298
+ 1,
299
+ 1,
300
+ getMarkdownTheme(),
301
+ { color: (text) => theme.fg("error", text) },
302
+ );
303
+ return [question, error];
304
+ }
305
+ const response: AssistantMessage = {
306
+ ...turn.response,
307
+ content: [{ type: "text", text: escapeTerminalControls(turn.answer) }],
308
+ stopReason: "stop",
309
+ errorMessage: undefined,
310
+ };
311
+ return [question, new AssistantMessageComponent(response, true, getMarkdownTheme(), "", 1)];
312
+ });
313
+ if (pendingQuestion) {
314
+ components.push(
315
+ new UserMessageComponent(escapeTerminalControls(pendingQuestion), getMarkdownTheme(), 1),
316
+ );
317
+ }
318
+ return components;
319
+ }
320
+
321
+ function renderTranscriptLines(components: readonly Component[], width: number): string[] {
322
+ return components
323
+ .flatMap((component) => component.render(width))
324
+ .map(stripShellIntegrationMarkers);
325
+ }
326
+
327
+ function renderSideThreadHeader(width: number, theme: Theme): string {
328
+ const title = truncateToWidth("─ btw · side thread ", width);
329
+ const ruleWidth = Math.max(0, width - visibleWidth(title));
330
+ return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
331
+ }
332
+
333
+ function fitComposerLayout(
334
+ header: string,
335
+ contentLines: string[],
336
+ footer: string,
337
+ editorLines: string[],
338
+ availableRows: number,
339
+ ): string[] {
340
+ const lines = [header, ...contentLines, footer, ...editorLines];
341
+ if (lines.length <= availableRows) return lines;
342
+ if (availableRows <= 1) return [header];
343
+ const editorBudget = Math.max(0, availableRows - 2);
344
+ return [header, footer, ...fitEditorLines(editorLines, editorBudget)];
345
+ }
346
+
347
+ function fitEditorLines(editorLines: string[], budget: number): string[] {
348
+ if (budget <= 0) return [];
349
+ if (editorLines.length <= budget) return editorLines;
350
+ const cursorIndex = editorLines.findIndex((line) => line.includes(CURSOR_MARKER));
351
+ if (cursorIndex < 0) return editorLines.slice(-budget);
352
+ const start = Math.min(cursorIndex, editorLines.length - budget);
353
+ return editorLines.slice(start, start + budget);
354
+ }
355
+
356
+ function fitWithFixedHeader(lines: string[], availableRows: number): string[] {
357
+ if (lines.length <= availableRows) return lines;
358
+ if (availableRows <= 1) return lines.slice(0, 1);
359
+ return [lines[0] ?? "", ...lines.slice(lines.length - availableRows + 1)];
360
+ }
361
+
362
+ function stripShellIntegrationMarkers(line: string): string {
363
+ return OSC133_MARKERS.reduce((result, marker) => result.replaceAll(marker, ""), line);
364
+ }
365
+
366
+ function escapeTerminalControls(text: string): string {
367
+ return [...text]
368
+ .map((character) => {
369
+ if (character === "\n") return character;
370
+ if (character === "\t") return " ";
371
+ const code = character.charCodeAt(0);
372
+ if (code <= 31 || (code >= 127 && code <= 159)) {
373
+ return `\\x${code.toString(16).padStart(2, "0")}`;
374
+ }
375
+ return character;
376
+ })
377
+ .join("");
378
+ }