Skip to content

Commit 45e1556

Browse files
committed
Colors, background headless, new mcps
1 parent c51732d commit 45e1556

File tree

6 files changed

+404
-72
lines changed

6 files changed

+404
-72
lines changed

kern.jsonc

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,34 @@
1212

1313
"processes": [
1414
{
15-
"name": "Counter",
16-
"command": "while true; do echo \"[$(date +%H:%M:%S)] tick $(( i++ ))\"; sleep 1; done"
15+
"name": "Worker",
16+
"command": "trap 'printf \"\\033[33m[Worker]\\033[0m SIGTERM received, finishing current job...\\n\"; sleep 2; printf \"\\033[33m[Worker]\\033[0m Job completed. Shutting down.\\n\"; exit 0' TERM; printf '\\033[1;35m[Worker]\\033[0m Connected to job queue\\n'; while true; do JOB=$((RANDOM % 1000)); printf '\\033[35m[Worker]\\033[0m Processing job \\033[1m#%d\\033[0m...\\n' \"$JOB\"; sleep 3; printf '\\033[35m[Worker]\\033[0m Job \\033[1m#%d\\033[0m \\033[32mdone\\033[0m\\n' \"$JOB\"; done",
17+
// Worker shuts down first (finish current job before API stops accepting)
18+
"shutdownOrder": 1
1719
},
1820
{
19-
"name": "Logger",
20-
"command": "for i in $(seq 1 50); do echo \"Log line $i\"; sleep 0.2; done; echo 'Done!' >&2",
21-
// Intentionally bad cwd to demo crash handling
22-
"cwd": "./logs"
21+
"name": "API Server",
22+
"command": "trap 'printf \"\\033[33m[API]\\033[0m Graceful shutdown: closing connections...\\n\"; sleep 1; printf \"\\033[33m[API]\\033[0m Draining request queue...\\n\"; sleep 1; printf \"\\033[33m[API]\\033[0m Shutdown complete.\\n\"; exit 0' TERM; printf '\\033[1;32m[API]\\033[0m Server started on \\033[36mhttp://localhost:3000\\033[0m\\n'; while true; do printf '\\033[32m[API]\\033[0m %s \\033[1mGET\\033[0m /api/health \\033[32m200\\033[0m %dms\\n' \"$(date +%H:%M:%S)\" \"$((RANDOM % 50 + 5))\"; sleep 2; done",
23+
// API + Cache shut down together (order 2)
24+
"shutdownOrder": 2
25+
},
26+
{
27+
"name": "Cache",
28+
"command": "trap 'printf \"\\033[33m[Cache]\\033[0m Persisting cache to disk...\\n\"; sleep 2; printf \"\\033[33m[Cache]\\033[0m Written 1247 keys. Shutdown complete.\\n\"; exit 0' TERM; printf '\\033[1;36m[Cache]\\033[0m Redis-like cache ready on port \\033[36m6379\\033[0m\\n'; while true; do printf '\\033[36m[Cache]\\033[0m %s \\033[90mHIT\\033[0m session:%d \\033[90m<1ms\\033[0m\\n' \"$(date +%H:%M:%S)\" \"$((RANDOM % 500))\"; sleep 3; done",
29+
// Cache shuts down with API (same order 2, so they stop in parallel)
30+
"shutdownOrder": 2
2331
},
2432
{
25-
"name": "Watcher",
26-
"command": "echo 'Watching files...' && while true; do echo \"$(date +%H:%M:%S) - no changes\"; sleep 3; done",
27-
// Pass custom environment variables
28-
"env": {
29-
"WATCH_DIR": "./src",
30-
"POLL_INTERVAL": "3"
31-
}
33+
"name": "DB",
34+
"command": "trap 'printf \"\\033[33m[DB]\\033[0m Flushing WAL to disk...\\n\"; sleep 1; printf \"\\033[33m[DB]\\033[0m Closing connections...\\n\"; sleep 1; printf \"\\033[33m[DB]\\033[0m Clean shutdown.\\n\"; exit 0' TERM; printf '\\033[1;34m[DB]\\033[0m Database ready on port \\033[36m5432\\033[0m\\n'; while true; do printf '\\033[34m[DB]\\033[0m %s \\033[90mquery\\033[0m SELECT * FROM users \\033[90m%dms\\033[0m\\n' \"$(date +%H:%M:%S)\" \"$((RANDOM % 10 + 1))\"; sleep 4; done",
35+
// DB shuts down last, after everything else is gone
36+
"shutdownOrder": 3
3237
},
3338
{
34-
"name": "Health Check",
35-
"command": "while true; do echo \"$(date +%H:%M:%S) healthy\"; sleep 5; done",
36-
// Skip the parent template for this process (runs on host even when parent is set)
37-
"noParent": true
39+
"name": "Migrations",
40+
"command": "printf '\\033[36m[Migrate]\\033[0m Running migrations...\\n'; sleep 1; printf '\\033[36m[Migrate]\\033[0m Applied: 001_create_users\\n'; sleep 0.5; printf '\\033[36m[Migrate]\\033[0m Applied: 002_add_sessions\\n'; sleep 0.5; printf '\\033[31m[Migrate]\\033[0m FATAL: column \"email\" already exists in \"users\"\\n' >&2; exit 1",
41+
// Crashes on its own — demos the red crashed status dot
42+
"cwd": "./logs"
3843
}
3944
]
4045
}

src/components/LogViewer.tsx

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useRef, useMemo, useEffect } from "react";
22
import { useKernTheme } from "../lib/theme-context.ts";
3+
import { parseAnsi, hasAnsi, stripAnsi } from "../lib/ansi.ts";
34
import type { ScrollBoxRenderable } from "@opentui/core";
45
import type { ProcessState, LogLine } from "../lib/types.ts";
56

@@ -28,12 +29,14 @@ function findMatchIndices(logs: LogLine[], query: string): number[] {
2829
try {
2930
const re = new RegExp(query, "i");
3031
for (let i = 0; i < logs.length; i++) {
31-
if (re.test(logs[i]!.text)) indices.push(i);
32+
const plain = stripAnsi(logs[i]!.text);
33+
if (re.test(plain)) indices.push(i);
3234
}
3335
} catch {
3436
const lower = query.toLowerCase();
3537
for (let i = 0; i < logs.length; i++) {
36-
if (logs[i]!.text.toLowerCase().includes(lower)) indices.push(i);
38+
const plain = stripAnsi(logs[i]!.text);
39+
if (plain.toLowerCase().includes(lower)) indices.push(i);
3740
}
3841
}
3942
return indices;
@@ -133,22 +136,28 @@ export function LogViewer({
133136
logs.map((line, i) => {
134137
const isMatch = matchSet.has(i);
135138
const isCurrent = i === targetLogIndex;
139+
// Override color: search current match > stderr > ANSI colors
140+
const overrideFg = isCurrent
141+
? colors.searchCurrentMatchText
142+
: line.stream === "stderr"
143+
? colors.stderrText
144+
: undefined;
145+
136146
return (
137147
<box
138148
key={i}
139149
paddingLeft={1}
140150
backgroundColor={isCurrent ? colors.searchCurrentMatchBackground : isMatch ? colors.searchMatchBackground : undefined}
141151
>
142-
<text
143-
fg={
144-
isCurrent
145-
? colors.searchCurrentMatchText
146-
: line.stream === "stderr"
147-
? colors.stderrText
148-
: undefined
152+
<text fg={overrideFg}>
153+
{overrideFg || !hasAnsi(line.text)
154+
? line.text.replace(/\x1b\[[0-9;]*m/g, "")
155+
: parseAnsi(line.text).map((seg, j) => (
156+
<span key={j} fg={seg.fg} bg={seg.bg}>
157+
{seg.text}
158+
</span>
159+
))
149160
}
150-
>
151-
{line.text}
152161
</text>
153162
</box>
154163
);

src/index.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,49 @@ if (args.includes("--headless")) {
88
console.error("Usage: kern --headless <config.jsonc> [config2.jsonc ...]");
99
process.exit(1);
1010
}
11+
12+
const { resolve } = await import("path");
13+
const { listSessions } = await import("./lib/session.ts");
14+
15+
const before = new Set((await listSessions()).map((s) => s.id));
16+
const absolutePaths = configPaths.map((p) => resolve(p));
17+
18+
// Spawn the daemon process in the background
19+
const isCompiled = process.execPath === process.argv[1] || !process.argv[1];
20+
const entryScript = isCompiled ? [] : [process.argv[1]!];
21+
const child = Bun.spawn([process.execPath, ...entryScript, "--daemon", ...absolutePaths], {
22+
stdio: ["ignore", "ignore", "ignore"],
23+
env: process.env,
24+
});
25+
child.unref();
26+
27+
// Poll for the session file (up to 5 seconds)
28+
const deadline = Date.now() + 5000;
29+
let newSession = null;
30+
while (Date.now() < deadline) {
31+
await new Promise((r) => setTimeout(r, 200));
32+
const current = await listSessions();
33+
newSession = current.find((s) => !before.has(s.id) && s.pid === child.pid);
34+
if (newSession) break;
35+
}
36+
37+
if (newSession) {
38+
console.log(`kern started in background`);
39+
console.log(` session: ${newSession.id}`);
40+
console.log(` pid: ${newSession.pid}`);
41+
console.log(` processes: ${newSession.processNames.join(", ")}`);
42+
console.log();
43+
console.log(`Attach: kern --attach ${newSession.id}`);
44+
console.log(`Stop: kern --stop ${newSession.id}`);
45+
} else {
46+
console.log(`kern spawned in background (pid ${child.pid})`);
47+
console.log(`Use 'kern --list' to see sessions once ready.`);
48+
}
49+
50+
process.exit(0);
51+
} else if (args.includes("--daemon")) {
52+
// Internal: the actual headless process running in the background
53+
const configPaths = args.filter((a) => a !== "--daemon");
1154
const { runHeadless } = await import("./headless.ts");
1255
await runHeadless(configPaths);
1356
} else if (args.includes("--attach")) {
@@ -64,10 +107,10 @@ if (args.includes("--headless")) {
64107
const configPaths = args;
65108
if (configPaths.length === 0) {
66109
console.error("Usage: kern <config.jsonc> [config2.jsonc ...]");
67-
console.error(" kern --headless <config.jsonc>");
68-
console.error(" kern --attach <session-id>");
69-
console.error(" kern --list");
70-
console.error(" kern --stop <session-id>");
110+
console.error(" kern --headless <config.jsonc> Start in background (detached)");
111+
console.error(" kern --attach <session-id> Attach to a session");
112+
console.error(" kern --list List active sessions");
113+
console.error(" kern --stop <session-id> Stop a session");
71114
process.exit(1);
72115
}
73116

src/lib/ansi.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/** Parse ANSI escape codes into styled segments for rendering */
2+
3+
export interface AnsiSegment {
4+
text: string;
5+
fg?: string;
6+
bg?: string;
7+
bold?: boolean;
8+
dim?: boolean;
9+
italic?: boolean;
10+
underline?: boolean;
11+
}
12+
13+
const ANSI_COLORS: Record<number, string> = {
14+
30: "#000000", 31: "#cc0000", 32: "#4e9a06", 33: "#c4a000",
15+
34: "#3465a4", 35: "#75507b", 36: "#06989a", 37: "#d3d7cf",
16+
90: "#555753", 91: "#ef2929", 92: "#8ae234", 93: "#fce94f",
17+
94: "#729fcf", 95: "#ad7fa8", 96: "#34e2e2", 97: "#eeeeec",
18+
};
19+
20+
const ANSI_BG_COLORS: Record<number, string> = {
21+
40: "#000000", 41: "#cc0000", 42: "#4e9a06", 43: "#c4a000",
22+
44: "#3465a4", 45: "#75507b", 46: "#06989a", 47: "#d3d7cf",
23+
100: "#555753", 101: "#ef2929", 102: "#8ae234", 103: "#fce94f",
24+
104: "#729fcf", 105: "#ad7fa8", 106: "#34e2e2", 107: "#eeeeec",
25+
};
26+
27+
// 256-color palette (indices 0-255)
28+
const COLOR_256: string[] = (() => {
29+
const palette: string[] = [];
30+
// 0-7: standard colors
31+
palette.push("#000000", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf");
32+
// 8-15: bright colors
33+
palette.push("#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec");
34+
// 16-231: 6x6x6 color cube
35+
for (let r = 0; r < 6; r++) {
36+
for (let g = 0; g < 6; g++) {
37+
for (let b = 0; b < 6; b++) {
38+
const rv = r === 0 ? 0 : 55 + r * 40;
39+
const gv = g === 0 ? 0 : 55 + g * 40;
40+
const bv = b === 0 ? 0 : 55 + b * 40;
41+
palette.push(`#${rv.toString(16).padStart(2, "0")}${gv.toString(16).padStart(2, "0")}${bv.toString(16).padStart(2, "0")}`);
42+
}
43+
}
44+
}
45+
// 232-255: grayscale
46+
for (let i = 0; i < 24; i++) {
47+
const v = 8 + i * 10;
48+
palette.push(`#${v.toString(16).padStart(2, "0")}${v.toString(16).padStart(2, "0")}${v.toString(16).padStart(2, "0")}`);
49+
}
50+
return palette;
51+
})();
52+
53+
// Regex to match ANSI escape sequences
54+
const ANSI_RE = /\x1b\[([0-9;]*)m/g;
55+
56+
interface AnsiState {
57+
fg?: string;
58+
bg?: string;
59+
bold?: boolean;
60+
dim?: boolean;
61+
italic?: boolean;
62+
underline?: boolean;
63+
}
64+
65+
function applyCode(state: AnsiState, codes: number[]): void {
66+
let i = 0;
67+
while (i < codes.length) {
68+
const code = codes[i]!;
69+
70+
if (code === 0) {
71+
state.fg = undefined;
72+
state.bg = undefined;
73+
state.bold = undefined;
74+
state.dim = undefined;
75+
state.italic = undefined;
76+
state.underline = undefined;
77+
} else if (code === 1) {
78+
state.bold = true;
79+
} else if (code === 2) {
80+
state.dim = true;
81+
} else if (code === 3) {
82+
state.italic = true;
83+
} else if (code === 4) {
84+
state.underline = true;
85+
} else if (code === 22) {
86+
state.bold = undefined;
87+
state.dim = undefined;
88+
} else if (code === 23) {
89+
state.italic = undefined;
90+
} else if (code === 24) {
91+
state.underline = undefined;
92+
} else if (code === 39) {
93+
state.fg = undefined;
94+
} else if (code === 49) {
95+
state.bg = undefined;
96+
} else if (code >= 30 && code <= 37) {
97+
state.fg = ANSI_COLORS[code];
98+
} else if (code >= 90 && code <= 97) {
99+
state.fg = ANSI_COLORS[code];
100+
} else if (code >= 40 && code <= 47) {
101+
state.bg = ANSI_BG_COLORS[code];
102+
} else if (code >= 100 && code <= 107) {
103+
state.bg = ANSI_BG_COLORS[code];
104+
} else if (code === 38 && codes[i + 1] === 5 && codes[i + 2] !== undefined) {
105+
state.fg = COLOR_256[codes[i + 2]!] ?? undefined;
106+
i += 2;
107+
} else if (code === 48 && codes[i + 1] === 5 && codes[i + 2] !== undefined) {
108+
state.bg = COLOR_256[codes[i + 2]!] ?? undefined;
109+
i += 2;
110+
} else if (code === 38 && codes[i + 1] === 2 && codes.length >= i + 5) {
111+
const r = codes[i + 2]!;
112+
const g = codes[i + 3]!;
113+
const b = codes[i + 4]!;
114+
state.fg = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
115+
i += 4;
116+
} else if (code === 48 && codes[i + 1] === 2 && codes.length >= i + 5) {
117+
const r = codes[i + 2]!;
118+
const g = codes[i + 3]!;
119+
const b = codes[i + 4]!;
120+
state.bg = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
121+
i += 4;
122+
}
123+
124+
i++;
125+
}
126+
}
127+
128+
export function parseAnsi(text: string): AnsiSegment[] {
129+
const segments: AnsiSegment[] = [];
130+
const state: AnsiState = {};
131+
let lastIndex = 0;
132+
133+
ANSI_RE.lastIndex = 0;
134+
let match: RegExpExecArray | null;
135+
136+
while ((match = ANSI_RE.exec(text)) !== null) {
137+
if (match.index > lastIndex) {
138+
const chunk = text.slice(lastIndex, match.index);
139+
if (chunk) {
140+
segments.push({
141+
text: chunk,
142+
...(state.fg && { fg: state.fg }),
143+
...(state.bg && { bg: state.bg }),
144+
...(state.bold && { bold: true }),
145+
...(state.dim && { dim: true }),
146+
...(state.italic && { italic: true }),
147+
...(state.underline && { underline: true }),
148+
});
149+
}
150+
}
151+
152+
const codeStr = match[1]!;
153+
const codes = codeStr === "" ? [0] : codeStr.split(";").map(Number);
154+
applyCode(state, codes);
155+
156+
lastIndex = ANSI_RE.lastIndex;
157+
}
158+
159+
if (lastIndex < text.length) {
160+
const chunk = text.slice(lastIndex);
161+
if (chunk) {
162+
segments.push({
163+
text: chunk,
164+
...(state.fg && { fg: state.fg }),
165+
...(state.bg && { bg: state.bg }),
166+
...(state.bold && { bold: true }),
167+
...(state.dim && { dim: true }),
168+
...(state.italic && { italic: true }),
169+
...(state.underline && { underline: true }),
170+
});
171+
}
172+
}
173+
174+
if (segments.length === 0 && text) {
175+
segments.push({ text });
176+
}
177+
178+
return segments;
179+
}
180+
181+
/** Check if text contains any ANSI escape codes */
182+
export function hasAnsi(text: string): boolean {
183+
return /\x1b\[/.test(text);
184+
}
185+
186+
/** Strip ANSI codes from text (for search, MCP output, etc.) */
187+
export function stripAnsi(text: string): string {
188+
return text.replace(/\x1b\[[0-9;]*m/g, "");
189+
}

0 commit comments

Comments
 (0)