@ramarivera/coding-buddy 0.7.0 → 0.7.1
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -2
- package/adapters/omp/renderers.ts +61 -4
- package/adapters/omp/ui.ts +1 -1
- package/cli/install.ts +12 -6
- package/hooks/buddy-comment.sh +10 -80
- package/hooks/file-type-react.sh +10 -402
- package/hooks/hooks.json +19 -5
- package/hooks/legacy/file-type-react.sh +403 -0
- package/hooks/legacy/mood-react.sh +210 -0
- package/hooks/legacy/name-react.sh +178 -0
- package/hooks/legacy/react.sh +1364 -0
- package/hooks/mood-react.sh +10 -209
- package/hooks/name-react.sh +10 -177
- package/hooks/react.sh +10 -1363
- package/hooks/suggest.sh +10 -54
- package/package.json +1 -1
- package/server/hooks/buddy-comment.ts +93 -0
- package/server/hooks/common.ts +95 -0
- package/server/hooks/legacy-bash.ts +34 -0
- package/server/hooks/suggest.ts +60 -0
- package/statusline/buddy-status.sh +8 -5
package/hooks/suggest.sh
CHANGED
|
@@ -1,55 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
LAST_SUGGESTION_FILE="$STATE_DIR/.last_suggestion"
|
|
13
|
-
SUGGESTIONS_FILE="$STATE_DIR/suggestions.json"
|
|
14
|
-
|
|
15
|
-
[ -f "$CONFIG_FILE" ] || exit 0
|
|
16
|
-
|
|
17
|
-
# Check if suggestions are enabled (default: true)
|
|
18
|
-
SUGGESTIONS_ENABLED="true"
|
|
19
|
-
if [ -f "$CONFIG_FILE" ]; then
|
|
20
|
-
_enabled=$(jq -r '.suggestionsEnabled // true' "$CONFIG_FILE" 2>/dev/null)
|
|
21
|
-
[ "$_enabled" = "false" ] && exit 0
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
# Check cooldown (default: 180s = 3 min)
|
|
25
|
-
SUGGESTION_COOLDOWN=180
|
|
26
|
-
if [ -f "$CONFIG_FILE" ]; then
|
|
27
|
-
_cd=$(jq -r '.suggestionCooldown // 180' "$CONFIG_FILE" 2>/dev/null || echo 180)
|
|
28
|
-
[[ "$_cd" =~ ^[0-9]+$ ]] && SUGGESTION_COOLDOWN=$_cd
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
if [ -f "$LAST_SUGGESTION_FILE" ]; then
|
|
32
|
-
LAST=$(cat "$LAST_SUGGESTION_FILE" 2>/dev/null)
|
|
33
|
-
NOW=$(date +%s)
|
|
34
|
-
DIFF=$(( NOW - ${LAST:-0} ))
|
|
35
|
-
[ "$DIFF" -lt "$SUGGESTION_COOLDOWN" ] && exit 0
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
INPUT=$(cat)
|
|
39
|
-
|
|
40
|
-
# Extract messages
|
|
41
|
-
ASSISTANT_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
|
|
42
|
-
[ -z "$ASSISTANT_MSG" ] && exit 0
|
|
43
|
-
|
|
44
|
-
# Record turn and check patterns
|
|
45
|
-
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
46
|
-
|
|
47
|
-
# Check for patterns via the suggestions script
|
|
48
|
-
if [ -x "$(command -v bun)" ]; then
|
|
49
|
-
# Run the suggestion check (async, non-blocking)
|
|
50
|
-
bun run "$PLUGIN_ROOT/server/check-suggestions.ts" \
|
|
51
|
-
"$(echo "$ASSISTANT_MSG" | jq -Rs .)" \
|
|
52
|
-
>/dev/null 2>&1 &
|
|
53
|
-
fi
|
|
54
|
-
|
|
55
|
-
exit 0
|
|
2
|
+
set +e
|
|
3
|
+
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
|
4
|
+
ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)" || exit 0
|
|
5
|
+
[ -n "$ROOT" ] || exit 0
|
|
6
|
+
BUN="$(command -v bun 2>/dev/null || true)"
|
|
7
|
+
[ -x "$BUN" ] || BUN="$HOME/.bun/bin/bun"
|
|
8
|
+
[ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
|
|
9
|
+
[ -x "$BUN" ] || BUN="/usr/local/bin/bun"
|
|
10
|
+
[ -x "$BUN" ] || exit 0
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/suggest.ts" || exit 0
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
defaultSpawnDetached,
|
|
7
|
+
fileExists,
|
|
8
|
+
isOnCooldown,
|
|
9
|
+
nonNegativeInteger,
|
|
10
|
+
parseHookInput,
|
|
11
|
+
readJsonFile,
|
|
12
|
+
readStdin,
|
|
13
|
+
resolveHookSessionId,
|
|
14
|
+
resolveHookStateDir,
|
|
15
|
+
stringField,
|
|
16
|
+
type HookRuntime,
|
|
17
|
+
} from "./common.ts";
|
|
18
|
+
|
|
19
|
+
interface BuddyConfig {
|
|
20
|
+
commentCooldown?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Events {
|
|
24
|
+
turns?: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
|
|
29
|
+
|
|
30
|
+
export interface BuddyCommentResult {
|
|
31
|
+
comment?: string;
|
|
32
|
+
updated: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractBuddyComment(message: string): string {
|
|
36
|
+
let comment = "";
|
|
37
|
+
for (const match of message.matchAll(BUDDY_COMMENT_PATTERN)) {
|
|
38
|
+
const candidate = match[1]?.trim();
|
|
39
|
+
if (candidate) comment = candidate;
|
|
40
|
+
}
|
|
41
|
+
return comment;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
|
|
45
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
46
|
+
if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
|
|
47
|
+
|
|
48
|
+
const input = parseHookInput(rawInput);
|
|
49
|
+
if (!input) return { updated: false };
|
|
50
|
+
|
|
51
|
+
const assistantMessage = stringField(input, "last_assistant_message");
|
|
52
|
+
if (!assistantMessage) return { updated: false };
|
|
53
|
+
|
|
54
|
+
const comment = extractBuddyComment(assistantMessage);
|
|
55
|
+
if (!comment) return { updated: false };
|
|
56
|
+
|
|
57
|
+
const now = runtime.now?.() ?? Date.now();
|
|
58
|
+
const sid = resolveHookSessionId(runtime);
|
|
59
|
+
const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
|
|
60
|
+
const cooldown = nonNegativeInteger(config.commentCooldown, 30);
|
|
61
|
+
const cooldownFile = join(stateDir, `.last_comment.${sid}`);
|
|
62
|
+
if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
|
|
63
|
+
|
|
64
|
+
mkdirSync(stateDir, { recursive: true });
|
|
65
|
+
writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(stateDir, `reaction.${sid}.json`),
|
|
68
|
+
JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const eventsFile = join(stateDir, "events.json");
|
|
72
|
+
const events = readJsonFile<Events>(eventsFile) ?? {};
|
|
73
|
+
events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
|
|
74
|
+
writeFileSync(eventsFile, JSON.stringify(events, null, 2));
|
|
75
|
+
|
|
76
|
+
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
|
|
77
|
+
spawnDetached("server/award-xp.ts", ["turn"]);
|
|
78
|
+
spawnDetached("server/consolidate.ts", [
|
|
79
|
+
assistantMessage,
|
|
80
|
+
stringField(input, "last_user_message"),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return { comment, updated: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (import.meta.main) {
|
|
87
|
+
try {
|
|
88
|
+
handleBuddyComment(await readStdin());
|
|
89
|
+
} catch {
|
|
90
|
+
// Claude Code hooks must never fail the turn.
|
|
91
|
+
}
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { buddyStateDir } from "../path.ts";
|
|
5
|
+
|
|
6
|
+
export type JsonObject = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface HookRuntime {
|
|
9
|
+
now?: () => number;
|
|
10
|
+
packageRoot?: string;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
spawnDetached?: (script: string, args: string[]) => void;
|
|
13
|
+
stateDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseHookInput(raw: string): JsonObject | null {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(raw) as unknown;
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? value as JsonObject
|
|
21
|
+
: null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stringField(input: JsonObject, field: string): string {
|
|
28
|
+
const value = input[field];
|
|
29
|
+
return typeof value === "string" ? value : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveHookStateDir(runtime: HookRuntime = {}): string {
|
|
33
|
+
return runtime.stateDir ?? buddyStateDir();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveHookSessionId(runtime: HookRuntime = {}): string {
|
|
37
|
+
if (runtime.sessionId) return runtime.sessionId;
|
|
38
|
+
const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
|
|
39
|
+
if (ccSid) return ccSid.slice(0, 8);
|
|
40
|
+
const pane = process.env.TMUX_PANE;
|
|
41
|
+
if (pane) return pane.replace(/^%/, "");
|
|
42
|
+
return "default";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readJsonFile<T>(path: string): T | null {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nonNegativeInteger(value: unknown, fallback: number): number {
|
|
54
|
+
if (Number.isInteger(value) && Number(value) >= 0) return Number(value);
|
|
55
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) return Number(value);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
62
|
+
if (!Number.isFinite(last)) return false;
|
|
63
|
+
return Math.floor(nowMs / 1000) - last < cooldownSeconds;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function packageRootFromHooks(): string {
|
|
70
|
+
return resolve(import.meta.dir, "..", "..");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function defaultSpawnDetached(runtime: HookRuntime = {}): (script: string, args: string[]) => void {
|
|
74
|
+
return (script, args) => {
|
|
75
|
+
const root = runtime.packageRoot ?? packageRootFromHooks();
|
|
76
|
+
const child = spawn(process.execPath, ["run", join(root, script), ...args], {
|
|
77
|
+
cwd: root,
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: "ignore",
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fileExists(path: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
return existsSync(path);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function readStdin(): Promise<string> {
|
|
94
|
+
return await Bun.stdin.text();
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { packageRootFromHooks, readStdin } from "./common.ts";
|
|
7
|
+
|
|
8
|
+
const ALLOWED = new Set([
|
|
9
|
+
"react.sh",
|
|
10
|
+
"file-type-react.sh",
|
|
11
|
+
"name-react.sh",
|
|
12
|
+
"mood-react.sh",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
if (import.meta.main) {
|
|
16
|
+
try {
|
|
17
|
+
const script = process.argv[2] ?? "";
|
|
18
|
+
if (ALLOWED.has(script)) {
|
|
19
|
+
const root = packageRootFromHooks();
|
|
20
|
+
const hook = join(root, "hooks", "legacy", script);
|
|
21
|
+
if (existsSync(hook)) {
|
|
22
|
+
spawnSync("bash", [hook], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
input: await readStdin(),
|
|
25
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
26
|
+
timeout: 14_000,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Claude Code hooks must never fail the turn.
|
|
32
|
+
}
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
defaultSpawnDetached,
|
|
6
|
+
fileExists,
|
|
7
|
+
isOnCooldown,
|
|
8
|
+
nonNegativeInteger,
|
|
9
|
+
parseHookInput,
|
|
10
|
+
readJsonFile,
|
|
11
|
+
readStdin,
|
|
12
|
+
resolveHookStateDir,
|
|
13
|
+
stringField,
|
|
14
|
+
type HookRuntime,
|
|
15
|
+
} from "./common.ts";
|
|
16
|
+
|
|
17
|
+
interface SuggestConfig {
|
|
18
|
+
suggestionCooldown?: unknown;
|
|
19
|
+
suggestionsEnabled?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SuggestHookResult {
|
|
23
|
+
checked: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function handleSuggest(rawInput: string, runtime: HookRuntime = {}): SuggestHookResult {
|
|
27
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
28
|
+
const configFile = join(stateDir, "config.json");
|
|
29
|
+
if (!fileExists(configFile)) return { checked: false };
|
|
30
|
+
|
|
31
|
+
const config = readJsonFile<SuggestConfig>(configFile) ?? {};
|
|
32
|
+
if (config.suggestionsEnabled === false || config.suggestionsEnabled === "false") {
|
|
33
|
+
return { checked: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = runtime.now?.() ?? Date.now();
|
|
37
|
+
const cooldown = nonNegativeInteger(config.suggestionCooldown, 180);
|
|
38
|
+
if (isOnCooldown(join(stateDir, ".last_suggestion"), cooldown, now)) {
|
|
39
|
+
return { checked: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const input = parseHookInput(rawInput);
|
|
43
|
+
if (!input) return { checked: false };
|
|
44
|
+
|
|
45
|
+
const assistantMessage = stringField(input, "last_assistant_message");
|
|
46
|
+
if (!assistantMessage) return { checked: false };
|
|
47
|
+
|
|
48
|
+
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
|
|
49
|
+
spawnDetached("server/check-suggestions.ts", [JSON.stringify(assistantMessage)]);
|
|
50
|
+
return { checked: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (import.meta.main) {
|
|
54
|
+
try {
|
|
55
|
+
handleSuggest(await readStdin());
|
|
56
|
+
} catch {
|
|
57
|
+
// Claude Code hooks must never fail the turn.
|
|
58
|
+
}
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
@@ -31,6 +31,7 @@ NAME=$(jq -r '.name // ""' "$STATE" 2>/dev/null)
|
|
|
31
31
|
[ -z "$NAME" ] && exit 0
|
|
32
32
|
|
|
33
33
|
RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
|
|
34
|
+
STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
|
|
34
35
|
SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
|
|
35
36
|
REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
|
|
36
37
|
REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
|
|
@@ -67,6 +68,7 @@ if [ -f "$CONFIG_FILE" ]; then
|
|
|
67
68
|
fi
|
|
68
69
|
|
|
69
70
|
NC=$'\033[0m'
|
|
71
|
+
NEUTRAL=$'\033[39m'
|
|
70
72
|
case "$RARITY" in
|
|
71
73
|
common)
|
|
72
74
|
[ "$_THEME" = "light" ] && C=$'\033[38;2;90;90;90m' || C=$'\033[38;2;153;153;153m' ;;
|
|
@@ -188,6 +190,7 @@ fi
|
|
|
188
190
|
# so the geometric center sits at col 6.
|
|
189
191
|
NAME_WITH_LEVEL="$NAME"
|
|
190
192
|
[ "$LEVEL" -gt 1 ] 2>/dev/null && NAME_WITH_LEVEL="${NAME} [L${LEVEL}]"
|
|
193
|
+
[ -n "$STARS" ] && NAME_WITH_LEVEL="${NAME_WITH_LEVEL} ${STARS}"
|
|
191
194
|
case "$MOOD" in
|
|
192
195
|
happy) MOOD_EMOJI="" ;;
|
|
193
196
|
focused) MOOD_EMOJI="" ;;
|
|
@@ -214,11 +217,11 @@ for line in "${ART_LINES[@]}"; do
|
|
|
214
217
|
if [ "$SHINY" = "true" ]; then
|
|
215
218
|
ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
|
|
216
219
|
else
|
|
217
|
-
ALL_COLORS+=("$
|
|
220
|
+
ALL_COLORS+=("$NEUTRAL")
|
|
218
221
|
fi
|
|
219
222
|
_arc=$(( _arc + 1 ))
|
|
220
223
|
done
|
|
221
|
-
ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$
|
|
224
|
+
ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
|
|
222
225
|
|
|
223
226
|
case "$SPECIES" in
|
|
224
227
|
axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
|
|
@@ -377,18 +380,18 @@ for (( i=0; i<MAX_LINES; i++ )); do
|
|
|
377
380
|
|
|
378
381
|
# Connector: "-- " on the middle text line, spaces otherwise
|
|
379
382
|
if [ $bi -eq $CONNECTOR_BI ]; then
|
|
380
|
-
gap="${
|
|
383
|
+
gap="${NEUTRAL}--${NC} "
|
|
381
384
|
else
|
|
382
385
|
gap=" "
|
|
383
386
|
fi
|
|
384
387
|
|
|
385
388
|
if [ "$btype" = "border" ]; then
|
|
386
|
-
echo "${SPACER}${
|
|
389
|
+
echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
|
|
387
390
|
else
|
|
388
391
|
pipe_l="${bline:0:1}"
|
|
389
392
|
pipe_r="${bline: -1}"
|
|
390
393
|
inner="${bline:1:$(( ${#bline} - 2 ))}"
|
|
391
|
-
echo "${SPACER}${
|
|
394
|
+
echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
|
|
392
395
|
fi
|
|
393
396
|
else
|
|
394
397
|
empty=$(printf '%*s' "$BOX_W" '')
|