feat(statusline): added wow-statusline package for editor status bar

- Created wow-statusline package with configurable editor footer showing CWD, git branch, model/provider info, thinking level, token usage, and cost.
- Registered `/wow:statusline:show` command to inspect the module's enabled state.
- Added `statusline?` config option to WowConfig, supporting boolean or `{ enabled?: boolean }` shape.
- Wired statusline into wow-pi extension entry point as a workspace dependency and side-effect import.
This commit is contained in:
kmou424
2026-06-17 00:23:35 +08:00
parent a46a5d703c
commit 9f95a34c18
15 changed files with 819 additions and 2 deletions

View File

@@ -22,7 +22,7 @@ wow-pi reads JSON or YAML config from both global and project locations, in orde
1. `~/.pi/agent/wow.json`, `~/.pi/agent/wow.yaml`, `~/.pi/agent/wow.yml`
2. `<project>/.pi/wow.json`, `<project>/.pi/wow.yaml`, `<project>/.pi/wow.yml`
Project config extends global config. Array fields are merged and de-duplicated; object fields are shallow-merged. See `wow.example.yaml` for a complete example.
Project config extends global config. Array fields are merged and de-duplicated; object fields are shallow-merged. For `statusline`, the supported public shape is intentionally minimal: `true`, `false`, or `{ enabled: boolean }`. See `wow.example.yaml` for an example.
## Packages
@@ -32,5 +32,6 @@ This repo is organized as a workspace monorepo:
- `packages/wow-contexts`: self-registering Markdown context injector with `/wow:contexts:list`, `/wow:contexts:reload`, and `/wow:init`.
- `packages/wow-inject`: self-registering env loader that reads `.env` files and resolves `${file:...}` / `${env:...}` into `process.env`.
- `packages/wow-pi`: pi extension entry point; imports feature packages for side-effect registration, then runs registered modules.
- `packages/wow-statusline`: customized statusline/footer module built on pi's supported UI APIs.
Logs are written to `~/.pi/agent/wow/logs/`.

View File

@@ -12,6 +12,7 @@ export interface WowConfig {
env?: Record<string, string> | Array<Record<string, string>>;
overrideExisting?: boolean;
};
statusline?: boolean | { enabled?: boolean };
[key: string]: unknown;
}

View File

@@ -15,7 +15,8 @@
"dependencies": {
"wow-contexts": "workspace:*",
"wow-core": "workspace:*",
"wow-inject": "workspace:*"
"wow-inject": "workspace:*",
"wow-statusline": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"

View File

@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import "wow-contexts";
import "wow-inject";
import "wow-statusline";
import {
createLogger,
getModules,

View File

@@ -0,0 +1,17 @@
{
"name": "wow-statusline",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"wow-core": "workspace:*"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
}
}

View File

@@ -0,0 +1,74 @@
import { getWowSettingSync } from "wow-core";
import type {
ResolvedStatuslineConfig,
StatuslineConfig,
StatuslineSection,
} from "./types";
const DEFAULT_SECTIONS: StatuslineSection[] = [
"cwd",
"branch",
"model",
"provider",
"thinking",
"usage",
"cost",
];
const BASE_STATUSLINE_CONFIG: Omit<
ResolvedStatuslineConfig,
"enabled" | "editorStyle"
> = {
footer: {
enabled: true,
sections: DEFAULT_SECTIONS,
pathStyle: "short",
branch: true,
providerStyle: "bracket",
separator: " · ",
},
indicator: {
enabled: false,
mode: "default",
},
statusSummary: {
enabled: true,
placement: "belowEditor",
maxItems: 4,
separator: " | ",
},
};
export function getStatuslineConfig(
cwd = process.cwd(),
): ResolvedStatuslineConfig {
const raw = getWowSettingSync<StatuslineConfig>("statusline", cwd);
return {
enabled: resolveEnabled(raw),
...BASE_STATUSLINE_CONFIG,
footer: {
...BASE_STATUSLINE_CONFIG.footer,
sections: [...BASE_STATUSLINE_CONFIG.footer.sections],
},
editorStyle: {
enabled: true,
prompt: "",
folderIcon: hasNerdFonts() ? "" : "📁",
indent: " ",
},
};
}
function resolveEnabled(value: StatuslineConfig | undefined): boolean {
if (typeof value === "boolean") return value;
if (value && typeof value === "object") return value.enabled !== false;
return true;
}
function hasNerdFonts(): boolean {
if (process.env.PI_NERD_FONTS === "1") return true;
if (process.env.PI_NERD_FONTS === "0") return false;
const term = process.env.TERM_PROGRAM || process.env.TERM || "";
return /iterm|wezterm|kitty|ghostty|alacritty/i.test(term);
}

View File

@@ -0,0 +1,124 @@
import type { Theme } from "@earendil-works/pi-coding-agent";
import type { ResolvedStatuslineConfig, SessionSnapshot } from "./types";
export const EMPTY_USAGE = { input: 0, output: 0, cost: 0 };
export function buildEditorTopLine(
theme: Theme,
snapshot: SessionSnapshot,
config: ResolvedStatuslineConfig,
): string {
const left: string[] = [];
const right: string[] = [];
const enabled = new Set(config.footer.sections);
if (enabled.has("cwd")) {
left.push(
theme.fg("accent", abbreviatePath(snapshot.cwd, config.footer.pathStyle)),
);
}
if (enabled.has("branch") && config.footer.branch && snapshot.branch) {
left.push(theme.fg("success", `(${snapshot.branch})`));
}
if (enabled.has("model")) {
right.push(theme.fg("accent", snapshot.modelName || "no-model"));
}
if (
enabled.has("provider") &&
snapshot.provider &&
config.footer.providerStyle !== "hidden"
) {
right[right.length - 1] =
config.footer.providerStyle === "text"
? `${right[right.length - 1]}${theme.fg("dim", ` provider:${snapshot.provider}`)}`
: `${right[right.length - 1]}${theme.fg("dim", ` [${snapshot.provider}]`)}`;
}
if (enabled.has("thinking") && snapshot.thinkingLevel !== "off") {
right.push(
theme.fg(thinkingColor(snapshot.thinkingLevel), snapshot.thinkingLevel),
);
}
if (enabled.has("usage") && (snapshot.usage.input || snapshot.usage.output)) {
right.push(
theme.fg(
"dim",
`${fmt(snapshot.usage.input)}${fmt(snapshot.usage.output)}`,
),
);
}
if (enabled.has("cost") && snapshot.usage.cost > 0) {
right.push(
theme.fg(
"warning",
`$${snapshot.usage.cost.toFixed(snapshot.usage.cost >= 1 ? 2 : 3)}`,
),
);
}
const leftText = left.join(theme.fg("dim", " "));
const rightText = right.join(theme.fg("dim", " | "));
if (!rightText) return leftText;
return `${leftText}${theme.fg("dim", config.footer.separator)}${rightText}`;
}
export function buildStatusSummary(
snapshot: SessionSnapshot,
config: ResolvedStatuslineConfig,
): string {
const values = snapshot.statuses
.map(([, text]) => text.trim())
.filter(Boolean)
.slice(0, config.statusSummary.maxItems);
return values.join(config.statusSummary.separator);
}
function abbreviatePath(pathValue: string, style: "full" | "short"): string {
const home = process.env.HOME || "";
let normalized = pathValue.replace(/\\/g, "/");
if (home && normalized.startsWith(home.replace(/\\/g, "/"))) {
normalized = `~${normalized.slice(home.length)}`;
}
if (style === "full") return normalized;
const parts = normalized.split("/").filter(Boolean);
if (normalized.startsWith("~/") && parts[0] !== "~") parts.unshift("~");
if (parts.length <= 3) return normalized;
const head = parts[0] || "";
const tail = parts[parts.length - 1] || "";
const middle = parts
.slice(1, -1)
.map((part) => part[0] || "")
.join("/");
return `${head}/${middle}/${tail}`;
}
function thinkingColor(
level: string,
):
| "thinkingMinimal"
| "thinkingLow"
| "thinkingMedium"
| "thinkingHigh"
| "thinkingXhigh" {
switch (level) {
case "minimal":
return "thinkingMinimal";
case "low":
return "thinkingLow";
case "high":
return "thinkingHigh";
case "xhigh":
return "thinkingXhigh";
default:
return "thinkingMedium";
}
}
function fmt(value: number): string {
if (value < 1000) return `${value}`;
if (value < 1_000_000)
return `${(value / 1000).toFixed(value < 10_000 ? 1 : 0)}k`;
return `${(value / 1_000_000).toFixed(1)}M`;
}

View File

@@ -0,0 +1,4 @@
import { createLogger } from "wow-core";
export const TAG = "statusline";
export const log = createLogger(TAG);

View File

@@ -0,0 +1,82 @@
import type {
ExtensionAPI,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { registerModule, resetWowConfigCache } from "wow-core";
import { getStatuslineConfig } from "./config";
import { TAG, log } from "./global";
import { createState } from "./state";
import {
applyStatuslineUI,
clearStatuslineUI,
updateStatuslineState,
} from "./ui";
const setup = (pi: ExtensionAPI): void => {
const state = createState();
let lastStreamingRefresh = 0;
const apply = (ctx: ExtensionContext): void => {
resetWowConfigCache();
state.currentCtx = ctx;
applyStatuslineUI(ctx, state, getStatuslineConfig(ctx.cwd));
};
const refresh = (): void => {
updateStatuslineState(state);
};
pi.on("session_start", async (_event, ctx) => {
state.thinkingLevel = pi.getThinkingLevel();
apply(ctx);
log.debug("statusline applied", { cwd: ctx.cwd });
});
pi.on("agent_start", async () => {
refresh();
});
pi.on("message_update", async () => {
const now = Date.now();
if (now - lastStreamingRefresh < 80) return;
lastStreamingRefresh = now;
refresh();
});
pi.on("model_select", async () => {
refresh();
});
pi.on("thinking_level_select", async (event) => {
state.thinkingLevel = event.level;
refresh();
});
pi.on("turn_end", async () => {
refresh();
});
pi.on("agent_end", async () => {
refresh();
});
pi.on("session_tree", async () => {
refresh();
});
pi.on("session_compact", async () => {
refresh();
});
pi.on("session_shutdown", async (_event, ctx) => {
clearStatuslineUI(ctx, state);
state.currentCtx = null;
state.requestRender = null;
state.footerData = null;
state.originalSetFooter = null;
state.footerHijacked = false;
state.lastRenderSignature = "";
});
};
registerModule({ name: TAG, register: setup });

View File

@@ -0,0 +1,104 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type {
FooterDataSnapshot,
SessionSnapshot,
UsageSummary,
} from "./types";
export interface StatuslineState {
currentCtx: ExtensionContext | null;
thinkingLevel: string;
statusEntries: Array<[string, string]>;
branch: string;
requestRender: (() => void) | null;
footerData: FooterDataSnapshot | null;
originalSetFooter: ((...args: unknown[]) => unknown) | null;
footerHijacked: boolean;
lastRenderSignature: string;
}
export function createState(): StatuslineState {
return {
currentCtx: null,
thinkingLevel: "off",
statusEntries: [],
branch: "",
requestRender: null,
footerData: null,
originalSetFooter: null,
footerHijacked: false,
lastRenderSignature: "",
};
}
export function snapshotFromContext(
state: StatuslineState,
ctx: ExtensionContext,
): SessionSnapshot {
return {
cwd: ctx.cwd,
branch: state.branch,
modelName: ctx.model?.name || ctx.model?.id || "no-model",
provider: ctx.model?.provider,
thinkingLevel: state.thinkingLevel,
usage: collectUsage(ctx),
statuses: [...state.statusEntries],
};
}
export function syncFooterSnapshot(state: StatuslineState): void {
if (!state.footerData) return;
state.branch = state.footerData.getGitBranch() || "";
const statuses = state.footerData.getExtensionStatuses();
if (statuses.size === 0) return;
state.statusEntries = Array.from(statuses.entries()).sort(([left], [right]) =>
left.localeCompare(right),
);
}
function collectUsage(ctx: ExtensionContext): UsageSummary {
let input = 0;
let output = 0;
let cost = 0;
try {
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message" || entry.message.role !== "assistant")
continue;
const usage = readUsage(entry.message);
if (!usage) continue;
input += usage.input;
output += usage.output;
cost += usage.cost;
}
} catch {
// Session access can fail during teardown or special modes.
}
return { input, output, cost };
}
function readUsage(message: unknown): UsageSummary | null {
if (!message || typeof message !== "object") return null;
const usage = (message as { usage?: unknown }).usage;
if (!usage || typeof usage !== "object") return null;
const input = readNumber((usage as { input?: unknown }).input);
const output = readNumber((usage as { output?: unknown }).output);
const costValue =
usage && typeof usage === "object"
? readNumber(
(
(usage as { cost?: unknown }).cost as
| { total?: unknown }
| undefined
)?.total,
)
: 0;
return { input, output, cost: costValue };
}
function readNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}

View File

@@ -0,0 +1,105 @@
import {
CustomEditor,
type ExtensionContext,
type KeybindingsManager,
type Theme,
} from "@earendil-works/pi-coding-agent";
import type { Component, EditorTheme, TUI } from "@earendil-works/pi-tui";
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { buildEditorTopLine, buildStatusSummary } from "./format";
import { snapshotFromContext, type StatuslineState } from "./state";
import type { ResolvedStatuslineConfig } from "./types";
const ANSI_SGR_RE = /\u001b\[[0-9;]*m/g;
export class EmptyFooter implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
export class StatuslineEditor extends CustomEditor {
private readonly extensionCtx: ExtensionContext;
private readonly statuslineState: StatuslineState;
private readonly statuslineConfig: ResolvedStatuslineConfig;
constructor(
tui: TUI,
theme: EditorTheme,
keybindings: KeybindingsManager,
ctx: ExtensionContext,
state: StatuslineState,
config: ResolvedStatuslineConfig,
) {
super(tui, theme, keybindings, { paddingX: 0 });
this.extensionCtx = ctx;
this.statuslineState = state;
this.statuslineConfig = config;
this.statuslineState.requestRender = tui.requestRender.bind(tui);
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 2);
const lines = super.render(innerWidth);
if (lines.length < 3) return lines;
const theme = this.extensionCtx.ui.theme;
const snapshot = snapshotFromContext(
this.statuslineState,
this.extensionCtx,
);
let bottom = lines.length - 1;
for (let index = lines.length - 1; index >= 1; index--) {
const plain = lines[index]!.replace(ANSI_SGR_RE, "");
if (plain.length > 0 && /^─{3,}/.test(plain)) {
bottom = index;
break;
}
}
const topText = buildEditorTopLine(theme, snapshot, this.statuslineConfig);
const folder = this.statuslineConfig.editorStyle.folderIcon;
const content = `${folder} ${topText}`;
const border = theme.fg("borderMuted", "─");
const contentWidth = visibleWidth(content);
const remaining = width - contentWidth - 2;
lines[0] =
remaining >= 1
? content + theme.fg("dim", " ") + border.repeat(remaining) + border
: border.repeat(Math.max(0, width));
if (lines.length > 1) {
lines[1] =
theme.fg("accent", this.statuslineConfig.editorStyle.prompt) +
theme.fg("dim", " ") +
(lines[1] ?? "");
}
lines[bottom] = border.repeat(Math.max(0, width));
for (let index = 0; index < lines.length; index++) {
if (visibleWidth(lines[index]!) > width) {
lines[index] = truncateToWidth(lines[index]!, width);
}
}
return lines;
}
}
export function buildStatuslineWidget(
ctx: ExtensionContext,
state: StatuslineState,
config: ResolvedStatuslineConfig,
): (width: number, theme: Theme) => string[] {
return (width, _theme) => {
const snapshot = snapshotFromContext(state, ctx);
const line = buildStatusSummary(snapshot, config);
if (!line) return [];
const padded = ` ${line}`;
return [truncateToWidth(padded, Math.max(0, width))];
};
}

View File

@@ -0,0 +1,60 @@
export type StatuslineSection =
| "cwd"
| "branch"
| "model"
| "provider"
| "thinking"
| "usage"
| "cost"
| "statuses";
export type StatuslineConfig = boolean | { enabled?: boolean };
export interface FooterDataSnapshot {
getGitBranch(): string | null;
getExtensionStatuses(): ReadonlyMap<string, string>;
}
export interface ResolvedStatuslineConfig {
enabled: boolean;
footer: {
enabled: boolean;
sections: StatuslineSection[];
pathStyle: "full" | "short";
branch: boolean;
providerStyle: "bracket" | "text" | "hidden";
separator: string;
};
indicator: {
enabled: boolean;
mode: "default" | "dot" | "pulse" | "none";
};
statusSummary: {
enabled: boolean;
placement: "aboveEditor" | "belowEditor";
maxItems: number;
separator: string;
};
editorStyle: {
enabled: boolean;
prompt: string;
folderIcon: string;
indent: string;
};
}
export interface UsageSummary {
input: number;
output: number;
cost: number;
}
export interface SessionSnapshot {
cwd: string;
branch: string;
modelName: string;
provider?: string;
thinkingLevel: string;
usage: UsageSummary;
statuses: Array<[string, string]>;
}

View File

@@ -0,0 +1,237 @@
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
import { buildEditorTopLine, buildStatusSummary, EMPTY_USAGE } from "./format";
import {
EmptyFooter,
StatuslineEditor,
buildStatuslineWidget,
} from "./statusline-editor";
import { syncFooterSnapshot, type StatuslineState } from "./state";
import type { FooterDataSnapshot, ResolvedStatuslineConfig } from "./types";
const STATUS_WIDGET_ID = "wow-statusline:summary";
const FOOTER_GUARD = "__wowStatuslineAllowFooter";
const FOOTER_HIJACKED = "__wowStatuslineFooterHijacked";
interface FooterDataLike extends FooterDataSnapshot {
onBranchChange(listener: () => void): () => void;
}
const SIGNATURE_CONFIG: ResolvedStatuslineConfig = {
enabled: true,
footer: {
enabled: true,
sections: [
"cwd",
"branch",
"model",
"provider",
"thinking",
"usage",
"cost",
],
pathStyle: "short",
branch: true,
providerStyle: "bracket",
separator: " · ",
},
indicator: { enabled: false, mode: "default" },
statusSummary: {
enabled: true,
placement: "belowEditor",
maxItems: 4,
separator: " | ",
},
editorStyle: {
enabled: true,
prompt: "",
folderIcon: "",
indent: " ",
},
};
export function applyStatuslineUI(
ctx: ExtensionContext,
state: StatuslineState,
config: ResolvedStatuslineConfig,
): void {
state.currentCtx = ctx;
if (!config.enabled) {
clearStatuslineUI(ctx, state);
return;
}
ctx.ui.setWorkingVisible(false);
hijackFooter(ctx, state);
installEmptyFooter(ctx, state);
ctx.ui.setWidget(
STATUS_WIDGET_ID,
(_tui, theme) => ({
render(width: number): string[] {
const render = buildStatuslineWidget(ctx, state, config);
return render(width, theme);
},
invalidate() {},
}),
{ placement: "belowEditor" },
);
ctx.ui.setEditorComponent(
(tui, theme, keybindings) =>
new StatuslineEditor(tui, theme, keybindings, ctx, state, config),
);
}
export function updateStatuslineState(state: StatuslineState): void {
syncFooterSnapshot(state);
if (!state.currentCtx) return;
const nextSignature = computeRenderSignature(state.currentCtx, state);
if (nextSignature === state.lastRenderSignature) return;
state.lastRenderSignature = nextSignature;
state.requestRender?.();
}
export function clearStatuslineUI(
ctx: ExtensionContext,
state: StatuslineState,
): void {
restoreFooter(ctx, state);
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "belowEditor" });
ctx.ui.setWidget(STATUS_WIDGET_ID, undefined, { placement: "aboveEditor" });
ctx.ui.setEditorComponent(undefined);
ctx.ui.setWorkingVisible(true);
ctx.ui.setWorkingIndicator();
}
function hijackFooter(ctx: ExtensionContext, state: StatuslineState): void {
const ui = ctx.ui as typeof ctx.ui & {
[FOOTER_HIJACKED]?: boolean;
[FOOTER_GUARD]?: boolean;
setFooter: (...args: unknown[]) => unknown;
};
if (ui[FOOTER_HIJACKED]) {
state.footerHijacked = true;
return;
}
const original = ui.setFooter.bind(ui);
state.originalSetFooter = original;
ui.setFooter = ((factory: unknown) => {
if (ui[FOOTER_GUARD]) {
return original(factory as Parameters<typeof original>[0]);
}
return undefined;
}) as typeof ui.setFooter;
ui[FOOTER_HIJACKED] = true;
state.footerHijacked = true;
}
function installEmptyFooter(
ctx: ExtensionContext,
state: StatuslineState,
): void {
const ui = ctx.ui as typeof ctx.ui & {
[FOOTER_GUARD]?: boolean;
setFooter: (...args: unknown[]) => unknown;
};
ui[FOOTER_GUARD] = true;
try {
const emptyFactory = (
_tui: unknown,
theme: Theme,
footerData: FooterDataLike,
) => {
captureFooterData(state, theme, footerData);
const dispose = footerData.onBranchChange(() => {
state.branch = footerData.getGitBranch() || "";
syncFooterSnapshot(state);
state.requestRender?.();
});
return new EmptyFooterWithDispose(dispose);
};
ui.setFooter(emptyFactory as Parameters<typeof ui.setFooter>[0]);
} finally {
ui[FOOTER_GUARD] = false;
}
}
function captureFooterData(
state: StatuslineState,
_theme: Theme,
footerData: FooterDataLike,
): void {
state.footerData = footerData;
syncFooterSnapshot(state);
}
function computeRenderSignature(
ctx: ExtensionContext,
state: StatuslineState,
): string {
const snapshot = {
cwd: ctx.cwd,
branch: state.branch,
model: ctx.model?.name || ctx.model?.id || "no-model",
provider: ctx.model?.provider || "",
thinking: state.thinkingLevel,
statuses: state.statusEntries
.map(([key, value]) => `${key}:${value}`)
.join("\u0001"),
};
const renderSnapshot = {
cwd: snapshot.cwd,
branch: snapshot.branch,
modelName: snapshot.model,
provider: snapshot.provider,
thinkingLevel: snapshot.thinking,
usage: EMPTY_USAGE,
statuses: state.statusEntries,
};
const top = buildEditorTopLine(
ctx.ui.theme,
renderSnapshot,
SIGNATURE_CONFIG,
);
const bottom = buildStatusSummary(renderSnapshot, SIGNATURE_CONFIG);
return `${top}\u0002${bottom}\u0002${snapshot.statuses}`;
}
function restoreFooter(ctx: ExtensionContext, state: StatuslineState): void {
const ui = ctx.ui as typeof ctx.ui & {
[FOOTER_HIJACKED]?: boolean;
[FOOTER_GUARD]?: boolean;
setFooter: (...args: unknown[]) => unknown;
};
if (state.originalSetFooter) {
ui.setFooter = state.originalSetFooter as typeof ui.setFooter;
}
ui[FOOTER_HIJACKED] = false;
ui[FOOTER_GUARD] = false;
state.originalSetFooter = null;
state.footerHijacked = false;
state.lastRenderSignature = "";
ui.setFooter(undefined);
}
class EmptyFooterWithDispose extends EmptyFooter {
constructor(private readonly disposeFn: () => void) {
super();
}
dispose(): void {
this.disposeFn();
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

@@ -18,5 +18,7 @@ inject:
ANTHROPIC_API_KEY: ${file:~/.secrets/anthropic-key}
OPENAI_API_KEY: ${env:OPENAI_API_KEY_BACKUP}
statusline: true
logger:
level: info