klyro 1.0.16 → 1.0.18
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/dist/agent/anthropic-adapter.js +26 -1
- package/dist/agent/provider-adapter.d.ts +7 -0
- package/dist/agent/provider-adapter.js +26 -1
- package/dist/agent/runtime.d.ts +14 -0
- package/dist/agent/runtime.js +59 -22
- package/dist/chat.js +16 -56
- package/dist/checkpoints/store.d.ts +15 -1
- package/dist/checkpoints/store.js +7 -2
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +10 -0
- package/dist/cli/completion.js +0 -3
- package/dist/cli/doctor.d.ts +13 -0
- package/dist/cli/doctor.js +133 -1
- package/dist/cli/errors.js +1 -1
- package/dist/cli/eval.d.ts +32 -0
- package/dist/cli/eval.js +83 -6
- package/dist/cli/markdown.js +1 -1
- package/dist/cli/repl.js +13 -24
- package/dist/cli/run.d.ts +37 -0
- package/dist/cli/run.js +81 -9
- package/dist/cli/update.js +7 -1
- package/dist/context/accounting.js +1 -1
- package/dist/context/klyro-md.d.ts +11 -0
- package/dist/context/klyro-md.js +59 -4
- package/dist/context/level7.d.ts +4 -0
- package/dist/context/level7.js +20 -1
- package/dist/context/project-map.js +14 -13
- package/dist/eval/baseline.d.ts +47 -0
- package/dist/eval/baseline.js +55 -0
- package/dist/eval/harness.d.ts +14 -2
- package/dist/eval/harness.js +42 -4
- package/dist/index.js +57 -3
- package/dist/persistence/store.js +2 -2
- package/dist/policy/approval.js +3 -0
- package/dist/policy/path-guard.d.ts +9 -4
- package/dist/policy/path-guard.js +47 -23
- package/dist/providers/endpoints.js +8 -0
- package/dist/providers/model-info.d.ts +1 -0
- package/dist/providers/model-info.js +19 -0
- package/dist/providers.js +6 -2
- package/dist/renderers/terminal.d.ts +7 -0
- package/dist/renderers/terminal.js +52 -0
- package/dist/repl.d.ts +9 -0
- package/dist/repl.js +24 -5
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/index.js +1 -0
- package/dist/shared/proxy.d.ts +37 -0
- package/dist/shared/proxy.js +360 -0
- package/dist/tools/agent/task-list.d.ts +1 -1
- package/dist/tools/fs/apply-patch.js +4 -4
- package/dist/tools/fs/edit-file.js +2 -4
- package/dist/tools/fs/list-dir.js +1 -1
- package/dist/tools/fs/multi-edit.js +2 -2
- package/dist/tools/fs/read-file.js +1 -1
- package/dist/tools/fs/write-file.js +2 -2
- package/dist/tools/git/git-blame.d.ts +5 -0
- package/dist/tools/git/git-blame.js +22 -0
- package/dist/tools/git/git-diff.js +3 -20
- package/dist/tools/git/git-log.js +5 -19
- package/dist/tools/git/git-show.d.ts +5 -0
- package/dist/tools/git/git-show.js +22 -0
- package/dist/tools/git/git-status.js +4 -21
- package/dist/tools/git/run-git.d.ts +5 -0
- package/dist/tools/git/run-git.js +18 -0
- package/dist/tools/lsp/diagnostics.js +2 -3
- package/dist/tools/plan/todo-write.d.ts +1 -1
- package/dist/tools/registry.js +4 -0
- package/dist/tools/search/dependencies.d.ts +4 -4
- package/dist/tools/search/dependencies.js +1 -1
- package/dist/tools/search/glob.js +9 -4
- package/dist/tools/search/grep.js +11 -4
- package/dist/tools/search/ignore.d.ts +8 -0
- package/dist/tools/search/ignore.js +49 -0
- package/dist/tools/search/recent-files.js +1 -1
- package/dist/tools/search/search-files.js +11 -4
- package/dist/tools/web/web-fetch.js +2 -1
- package/dist/tools/web/web-search.js +3 -1
- package/dist/tui/app.js +117 -10
- package/dist/tui/app.test.js +65 -4
- package/dist/tui/measure.js +1 -0
- package/dist/tui/mouse.d.ts +7 -5
- package/dist/tui/mouse.js +9 -6
- package/dist/tui/scroll-flow.test.js +0 -1
- package/dist/tui/transcript-commands.d.ts +14 -0
- package/dist/tui/transcript-commands.js +31 -0
- package/dist/util/log.d.ts +19 -10
- package/dist/util/log.js +87 -52
- package/dist/verification/classify.d.ts +1 -1
- package/dist/verification/classify.js +2 -2
- package/dist/verification/engine.js +0 -6
- package/dist/verification/scoped.js +6 -4
- package/package.json +6 -1
package/dist/tui/app.js
CHANGED
|
@@ -7,6 +7,8 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
|
|
7
7
|
import { Box, Text, useInput, useStdout } from 'ink';
|
|
8
8
|
import Spinner from 'ink-spinner';
|
|
9
9
|
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import * as path from 'node:path';
|
|
10
12
|
import { TuiApprovalBridge, ApprovalModal } from './approval.js';
|
|
11
13
|
import { parse as parseSlash, suggestCommands } from '../cli/slash/parser.js';
|
|
12
14
|
import { loadCustomCommands, listCompletableFiles } from '../cli/slash/custom.js';
|
|
@@ -33,6 +35,40 @@ function Header({ cwd, model, version, width }) {
|
|
|
33
35
|
const showLinks = width >= 120;
|
|
34
36
|
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsxs(Text, { bold: true, color: tokens.colors.accent, children: ["KLYRO v", version] }), showLinks ? _jsx(Text, { color: tokens.colors.dim, children: "\u2502 /help /config /clear /exit" }) : null] }), _jsxs(Text, { color: tokens.colors.dim, children: [model, " \u00B7 ", cwd] }), branch ? _jsxs(Text, { color: tokens.colors.dim, children: ["\u2387 ", branch] }) : null] }));
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 5.5c — compact todo checklist. Live `plan` state (`todo_write` →
|
|
40
|
+
* `plan_update` events → `updatePlan`) wins; when it is empty, best-effort
|
|
41
|
+
* read of `.klyro/plans/todos.json` so a reloaded TUI still shows the list.
|
|
42
|
+
*/
|
|
43
|
+
const TODO_STATUSES = ['pending', 'in_progress', 'done', 'failed', 'skipped'];
|
|
44
|
+
function loadTodosFromDisk(cwd) {
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(path.join(cwd, '.klyro', 'plans', 'todos.json'), 'utf-8');
|
|
47
|
+
const arr = JSON.parse(raw);
|
|
48
|
+
if (!Array.isArray(arr))
|
|
49
|
+
return [];
|
|
50
|
+
return arr
|
|
51
|
+
.filter((t) => typeof t.title === 'string' && t.title.length > 0)
|
|
52
|
+
.map((t, i) => ({
|
|
53
|
+
id: typeof t.id === 'string' ? t.id : `disk-${i}`,
|
|
54
|
+
title: t.title,
|
|
55
|
+
status: TODO_STATUSES.includes(t.status)
|
|
56
|
+
? t.status
|
|
57
|
+
: 'pending',
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function TodoChecklist({ plan, cwd }) {
|
|
65
|
+
// Disk fallback only when live state is empty (never masks live updates).
|
|
66
|
+
const disk = useMemo(() => (plan.length > 0 ? [] : loadTodosFromDisk(cwd)), [plan.length, cwd]);
|
|
67
|
+
const items = plan.length > 0 ? plan : disk;
|
|
68
|
+
if (items.length === 0)
|
|
69
|
+
return null;
|
|
70
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginTop: 0, marginBottom: 1, children: [_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { bold: true, children: [g('todoPlan'), " Plan ", items.filter((p) => p.status === 'done').length, "/", items.length] })] }), items.slice(0, 8).map((p, i) => (_jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: p.status === 'done' ? tokens.colors.ok : p.status === 'in_progress' ? tokens.colors.accent : tokens.colors.dim, children: [p.status === 'done' ? g('todoDone') : p.status === 'in_progress' ? g('todoActive') : g('todoPending'), " ", i + 1, ". ", redact(p.title)] })] }, p.id)))] }));
|
|
71
|
+
}
|
|
36
72
|
function verbForTool(name) {
|
|
37
73
|
if (name === 'read_file')
|
|
38
74
|
return 'Read';
|
|
@@ -229,6 +265,14 @@ export function App(props) {
|
|
|
229
265
|
setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
|
|
230
266
|
setHistIdx(null);
|
|
231
267
|
}, []);
|
|
268
|
+
// Ctrl+R reverse-i-search over input history (1.4): {query, saved, idx}
|
|
269
|
+
// with idx into newest-first matches (-1 = query only, nothing chosen).
|
|
270
|
+
const [histSearch, setHistSearch] = useState(null);
|
|
271
|
+
const searchMatches = useCallback((q) => {
|
|
272
|
+
const query = q.toLowerCase();
|
|
273
|
+
const matches = (query === '' ? history : history.filter((h) => h.toLowerCase().includes(query)));
|
|
274
|
+
return matches.slice().reverse();
|
|
275
|
+
}, [history]);
|
|
232
276
|
// design.md §18: Ctrl+C cancels the run first, exits on second press.
|
|
233
277
|
const ctrlCArmed = useRef(false);
|
|
234
278
|
useEffect(() => {
|
|
@@ -484,7 +528,6 @@ export function App(props) {
|
|
|
484
528
|
out.push({ key: 'tail:queued', desc: { kind: 'queued', count: queuedInputs.length }, groupIndex: null, tail: 'queued' });
|
|
485
529
|
}
|
|
486
530
|
return out;
|
|
487
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
488
531
|
}, [grouped, plan, status.status, queuedInputs.length, expandedGroups, width]);
|
|
489
532
|
const blockKeys = useMemo(() => blocks.map((b) => b.key), [blocks]);
|
|
490
533
|
const blockHeights = useMemo(() => blocks.map((b) => cache.heightFor(b.key, b.desc, width)), [blocks, width, cache]);
|
|
@@ -762,6 +805,56 @@ export function App(props) {
|
|
|
762
805
|
setVimCursor(null);
|
|
763
806
|
return;
|
|
764
807
|
}
|
|
808
|
+
// Ctrl+R reverse-i-search over input history (1.4). While active, typing
|
|
809
|
+
// filters newest-first, ↑/↓ (or repeated Ctrl+R) cycle matches, Enter
|
|
810
|
+
// accepts into the buffer, Esc/Ctrl+C restores the saved input.
|
|
811
|
+
if (histSearch && !awaitingApproval) {
|
|
812
|
+
// idx into newest-first matches; -1 shows the raw query text.
|
|
813
|
+
const applyQuery = (query, idx) => {
|
|
814
|
+
const m = searchMatches(query);
|
|
815
|
+
const i = m.length === 0 ? -1 : Math.max(-1, Math.min(idx, m.length - 1));
|
|
816
|
+
setHistSearch({ query, saved: histSearch.saved, idx: i });
|
|
817
|
+
setInput(i === -1 ? query : (m[i] ?? query));
|
|
818
|
+
setVimCursor(null);
|
|
819
|
+
};
|
|
820
|
+
if (key.escape || (key.ctrl && inputStr === 'c')) {
|
|
821
|
+
setInput(histSearch.saved);
|
|
822
|
+
setHistIdx(null);
|
|
823
|
+
setHistSearch(null);
|
|
824
|
+
setVimCursor(null);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (key.return) {
|
|
828
|
+
setHistSearch(null);
|
|
829
|
+
setHistIdx(null);
|
|
830
|
+
setVimCursor(null);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if ((key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && (inputStr === 'p' || inputStr === 'r'))) {
|
|
834
|
+
applyQuery(histSearch.query, histSearch.idx + 1); // older (clamped)
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if ((key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n')) {
|
|
838
|
+
applyQuery(histSearch.query, histSearch.idx - 1); // newer, -1 → query
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (key.backspace || key.delete) {
|
|
842
|
+
applyQuery(histSearch.query.slice(0, -1), 0);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (!key.ctrl && !key.meta && inputStr) {
|
|
846
|
+
applyQuery(histSearch.query + inputStr, 0);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (key.ctrl && inputStr === 'r' && !awaitingApproval && vimMode === 'insert') {
|
|
852
|
+
setHistSearch({ query: '', saved: input, idx: -1 });
|
|
853
|
+
setInput('');
|
|
854
|
+
setHistIdx(null);
|
|
855
|
+
setVimCursor(null);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
765
858
|
// Scroll keys (work in any mode, including while running).
|
|
766
859
|
// scroll.md §6 flow: Keyboard → getTranscriptCommand → handle.
|
|
767
860
|
// design.md §11: PageUp/Ctrl+U half-up, PageDown/Ctrl+D half-down,
|
|
@@ -988,6 +1081,19 @@ export function App(props) {
|
|
|
988
1081
|
return;
|
|
989
1082
|
}
|
|
990
1083
|
}
|
|
1084
|
+
// Ctrl+L (1.4): clear the input buffer if typing, else jump to the live
|
|
1085
|
+
// tail. Never touches the transcript — use /clear for that.
|
|
1086
|
+
if (key.ctrl && inputStr === 'l') {
|
|
1087
|
+
if (input.trim() !== '') {
|
|
1088
|
+
setInput('');
|
|
1089
|
+
setVimCursor(null);
|
|
1090
|
+
setHistIdx(null);
|
|
1091
|
+
}
|
|
1092
|
+
else if (isFullscreen && maxTop > 0) {
|
|
1093
|
+
commands.jumpBottom();
|
|
1094
|
+
}
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
991
1097
|
if (status.status === 'running') {
|
|
992
1098
|
// design.md §18: first Ctrl+C cancels the run, second quits.
|
|
993
1099
|
if (key.ctrl && inputStr === 'c') {
|
|
@@ -1055,7 +1161,7 @@ export function App(props) {
|
|
|
1055
1161
|
// ↑/↓ are history keys, always (§8.3): ↑ browses older prompts
|
|
1056
1162
|
// newest-first (empty history → no-op), ↓ browses back newer. Neither
|
|
1057
1163
|
// ever scrolls the transcript — scrolling is PgUp/PgDn, Ctrl+U/D/B/F,
|
|
1058
|
-
// Shift+↑/↓, Home/End, Space, or the wheel
|
|
1164
|
+
// Shift+↑/↓, Home/End, Space, or the wheel. Ctrl+P /
|
|
1059
1165
|
// Ctrl+N are escape-free aliases for the same history moves.
|
|
1060
1166
|
const wantHistPrev = (key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'p');
|
|
1061
1167
|
const wantHistNext = (key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n');
|
|
@@ -1121,7 +1227,6 @@ export function App(props) {
|
|
|
1121
1227
|
});
|
|
1122
1228
|
// Single source of truth: package.json via version.ts — never hardcoded.
|
|
1123
1229
|
const ver = props.version ?? readVersion();
|
|
1124
|
-
const rule = g('rule').repeat(Math.max(10, width - 2));
|
|
1125
1230
|
// Single cost/context source: model-aware registry rates + window.
|
|
1126
1231
|
const cost = estimateCost(status.model, status.usageInput, status.usageOutput);
|
|
1127
1232
|
const totalTokens = status.usageInput + status.usageOutput;
|
|
@@ -1129,12 +1234,14 @@ export function App(props) {
|
|
|
1129
1234
|
const ctxPct = totalTokens > 0 ? Math.round((totalTokens / ctxWindow) * 100) : 0;
|
|
1130
1235
|
// Narrow terminals: compact hints so the status bar never wraps mid-word.
|
|
1131
1236
|
// An armed first-Esc surfaces here so the operator knows the second cancels.
|
|
1132
|
-
const baseHints =
|
|
1133
|
-
?
|
|
1134
|
-
:
|
|
1135
|
-
?
|
|
1136
|
-
:
|
|
1137
|
-
|
|
1237
|
+
const baseHints = histSearch !== null
|
|
1238
|
+
? `reverse-i-search \`${histSearch.query}\` · type to filter · ↑/↓ cycle · enter accept · esc cancel`
|
|
1239
|
+
: escArmed && status.status === 'running'
|
|
1240
|
+
? 'press esc again to cancel'
|
|
1241
|
+
: width < 90
|
|
1242
|
+
? status.status === 'running' ? 'ctrl+c stop · enter queue' : 'enter send · / commands'
|
|
1243
|
+
: status.status === 'running' ? 'ctrl+c to stop · enter to queue · ctrl+o expand' : transcript.length === 0 ? 'shift+tab to cycle · ↑/↓ for history · / for commands' : 'enter to send · shift+enter newline · @ to attach';
|
|
1244
|
+
// I1 structural guard:
|
|
1138
1245
|
// I1 structural guard: the frame can never exceed terminal rows. Even if a
|
|
1139
1246
|
// child mis-measures, the root clips — the input/status stay on screen and
|
|
1140
1247
|
// Ink's cursor math can't corrupt into overlapping text.
|
|
@@ -1216,5 +1323,5 @@ export function App(props) {
|
|
|
1216
1323
|
if (it.kind === 'diff')
|
|
1217
1324
|
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: [_jsx(Text, { bold: true, color: tokens.colors.soft, children: it.summary ?? 'Diff' }), it.hunks.map((h, i) => (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsx(Text, { color: tokens.colors.soft, children: h.path }), h.lines.map((l, j) => (_jsxs(Text, { wrap: "wrap", color: l.kind === 'add' ? tokens.colors.ok : l.kind === 'remove' ? tokens.colors.err : tokens.colors.dim, children: [l.kind === 'add' ? '+ ' : l.kind === 'remove' ? '- ' : ' ', redact(l.text)] }, j)))] }, i)))] }, it.id));
|
|
1218
1325
|
return null;
|
|
1219
|
-
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan
|
|
1326
|
+
}) : null, showThinking && status.status === 'running' && !streamingIdRef.current ? (_jsxs(Box, { paddingLeft: 2, marginBottom: 1, children: [_jsxs(Text, { color: tokens.colors.guide, children: [" ", g('guide'), " "] }), _jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " "] }), _jsx(Text, { color: tokens.colors.dim, children: "Thinking... (esc \u00D72 to cancel)" }), _jsxs(Text, { color: tokens.colors.dim, children: [" ", (elapsed / 1000).toFixed(1), "s"] })] })) : null, showPlan ? _jsx(TodoChecklist, { plan: plan, cwd: props.cwd }) : null, showQueued && queuedInputs.length > 0 ? (_jsx(Box, { flexDirection: "column", paddingLeft: 2, marginBottom: 1, children: queuedInputs.map((q, i) => (_jsxs(Text, { color: tokens.colors.dim, children: ["queued: ", redact(q.slice(0, 60)), i === 0 ? ' esc to drop' : ''] }, i))) })) : null] }), isFullscreen && !atBottom ? (_jsx(Box, { flexDirection: "column", width: 1, marginLeft: 1, children: Array.from({ length: trackH }).map((_, i) => (_jsx(Text, { color: i === thumbPos ? tokens.colors.accent : tokens.colors.guide, children: i === thumbPos ? '●' : '│' }, i))) })) : null] }), pinned && badgeLabel(atBottom, pendingNew, status.status !== 'running') ? (_jsx(Box, { justifyContent: "center", flexShrink: 0, children: _jsxs(Text, { backgroundColor: tokens.colors.accentSoft, color: tokens.colors.accent, bold: true, children: [' ', badgeLabel(atBottom, pendingNew, status.status !== 'running'), ' '] }) })) : null, slashSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [slashSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " /", s.name, " \u2014 ", s.hint] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, slashSuggest.length === 0 && pathSuggest.length > 0 ? (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, flexShrink: 0, children: [pathSuggest.map((s, i) => (_jsxs(Text, { color: i === 0 ? tokens.colors.accent : tokens.colors.dim, children: [i === 0 ? '▸' : ' ', " ", s.name] }, s.name))), _jsx(Text, { color: tokens.colors.dim, children: " tab to complete" })] })) : null, _jsx(ApprovalModal, { bridge: bridge }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) }), _jsxs(Box, { children: [_jsxs(Text, { color: tokens.colors.accent, bold: true, children: [g('prompt'), " "] }), vimMode === 'normal' ? _jsx(Text, { color: tokens.colors.warn, bold: true, children: "--NORMAL-- " }) : null, _jsx(InputWithCursor, { input: input, cursor: vimCursor })] }), _jsx(Text, { color: tokens.colors.guide, children: g('rule').repeat(Math.max(10, width - 2)) })] }), _jsxs(Box, { justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Text, { color: tokens.colors.dim, children: [status.status === 'running' ? (_jsxs(Text, { color: tokens.colors.accent, children: [_jsx(Spinner, { type: "dots" }), " working \u00B7 "] })) : null, baseHints, maxTop > 0 && isFullscreen ? ' · PgUp/Dn scroll' : ''] }), _jsxs(Text, { color: tokens.colors.dim, children: [maxTop > 0 && isFullscreen ? `⇅ ${topRow}/${maxTop} · ` : '', cost > 0 ? `$${cost.toFixed(2)} · ` : '', ctxPct, "% ctx \u00B7 ", status.model, status.status === 'running' ? ' ●' : ''] })] })] }));
|
|
1220
1327
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -215,9 +215,9 @@ describe('App', () => {
|
|
|
215
215
|
});
|
|
216
216
|
it('pressing End re-engages follow-tail and reveals new content', async () => {
|
|
217
217
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
218
|
-
await new Promise((r) => setTimeout(r,
|
|
218
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
219
219
|
stdin.write(KEY_HOME);
|
|
220
|
-
await new Promise((r) => setTimeout(r,
|
|
220
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
221
221
|
const g = globalThis;
|
|
222
222
|
g.__klyroAppAppend({
|
|
223
223
|
id: 'late-1',
|
|
@@ -229,7 +229,7 @@ describe('App', () => {
|
|
|
229
229
|
expect(lastFrame() ?? '').not.toMatch(/LATE-1-tag/);
|
|
230
230
|
// End re-engages follow-tail and shows the new content.
|
|
231
231
|
stdin.write(KEY_END);
|
|
232
|
-
await new Promise((r) => setTimeout(r,
|
|
232
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
233
233
|
const frame = lastFrame() ?? '';
|
|
234
234
|
expect(frame).toMatch(/LATE-1-tag/);
|
|
235
235
|
});
|
|
@@ -321,6 +321,53 @@ describe('App', () => {
|
|
|
321
321
|
await new Promise((r) => setTimeout(r, 30));
|
|
322
322
|
expect(lastFrame() ?? '').toContain('zzsecond#');
|
|
323
323
|
});
|
|
324
|
+
it('Ctrl+R reverse-searches history; Enter accepts, Esc restores (1.4)', async () => {
|
|
325
|
+
const onPrompt = vi.fn(async () => { });
|
|
326
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
|
|
327
|
+
stdin.write('fix login bug');
|
|
328
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
329
|
+
stdin.write('\x0d');
|
|
330
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
331
|
+
stdin.write('add logout button');
|
|
332
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
333
|
+
stdin.write('\x0d');
|
|
334
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
335
|
+
expect(onPrompt).toHaveBeenCalledTimes(2);
|
|
336
|
+
// Search for 'logout' + Enter accepts the match into the buffer
|
|
337
|
+
// (no submit); typing '!' must extend it in the input line only.
|
|
338
|
+
stdin.write('\x12');
|
|
339
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
340
|
+
stdin.write('logout');
|
|
341
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
342
|
+
stdin.write('\x0d');
|
|
343
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
344
|
+
stdin.write('!');
|
|
345
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
346
|
+
expect(lastFrame() ?? '').toContain('add logout button!');
|
|
347
|
+
// Esc cancels a fresh search and restores the pre-search buffer.
|
|
348
|
+
stdin.write('\x12');
|
|
349
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
350
|
+
stdin.write('zzz');
|
|
351
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
352
|
+
stdin.write('\x1b');
|
|
353
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
354
|
+
stdin.write('?');
|
|
355
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
356
|
+
expect(lastFrame() ?? '').not.toContain('zzz?');
|
|
357
|
+
expect(onPrompt).toHaveBeenCalledTimes(2); // searching never submits
|
|
358
|
+
});
|
|
359
|
+
it('Ctrl+L clears a non-empty input without submitting (1.4)', async () => {
|
|
360
|
+
const onPrompt = vi.fn(async () => { });
|
|
361
|
+
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
|
|
362
|
+
stdin.write('scratch that');
|
|
363
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
364
|
+
stdin.write('\x0c');
|
|
365
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
366
|
+
stdin.write('?');
|
|
367
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
368
|
+
expect(lastFrame() ?? '').not.toContain('scratch that');
|
|
369
|
+
expect(onPrompt).not.toHaveBeenCalled();
|
|
370
|
+
});
|
|
324
371
|
it('/c shows top-6 suggestions and Tab completes', async () => {
|
|
325
372
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
326
373
|
stdin.write('/c');
|
|
@@ -448,7 +495,7 @@ describe('App', () => {
|
|
|
448
495
|
await waitForAbsent(lastFrame, /MSG-24-tag/);
|
|
449
496
|
await waitForMatch(lastFrame, /MSG-14-tag/);
|
|
450
497
|
handle.runTranscriptCommand('messages_first');
|
|
451
|
-
|
|
498
|
+
const frame = await waitForMatch(lastFrame, /MSG-00-tag/);
|
|
452
499
|
expect(frame).not.toMatch(/MSG-24-tag/);
|
|
453
500
|
handle.runTranscriptCommand('messages_last');
|
|
454
501
|
await waitForMatch(lastFrame, /MSG-24-tag/);
|
|
@@ -710,6 +757,20 @@ describe('App', () => {
|
|
|
710
757
|
expect(f2() ?? '').toContain(readVersion());
|
|
711
758
|
expect(f2() ?? '').not.toContain('0.1.27');
|
|
712
759
|
});
|
|
760
|
+
it('shows the todo checklist when plan updates arrive (5.5c)', async () => {
|
|
761
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
|
|
762
|
+
const g = globalThis;
|
|
763
|
+
for (let i = 0; i < 40 && !g.__klyroAppPlan; i++)
|
|
764
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
765
|
+
expect(g.__klyroAppPlan).toBeDefined();
|
|
766
|
+
g.__klyroAppPlan?.([
|
|
767
|
+
{ id: '1', title: 'Read files', status: 'done' },
|
|
768
|
+
{ id: '2', title: 'Edit code', status: 'in_progress' },
|
|
769
|
+
]);
|
|
770
|
+
const frame = await waitForMatch(lastFrame, /Read files/);
|
|
771
|
+
expect(frame).toMatch(/Plan/);
|
|
772
|
+
expect(frame).toMatch(/Edit code/);
|
|
773
|
+
});
|
|
713
774
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
714
775
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
715
776
|
await new Promise((r) => setTimeout(r, 50));
|
package/dist/tui/measure.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* plan block = 1 + min(8, steps) + 1 margin
|
|
18
18
|
* thinking/queue = content lines + 1 margin
|
|
19
19
|
*/
|
|
20
|
+
// eslint-disable-next-line no-control-regex -- intentional: measuring visible width requires skipping SGR sequences
|
|
20
21
|
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
21
22
|
function isWide(cp) {
|
|
22
23
|
return ((cp >= 0x1100 && cp <= 0x115f) ||
|
package/dist/tui/mouse.d.ts
CHANGED
|
@@ -37,11 +37,13 @@ export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
|
|
|
37
37
|
/**
|
|
38
38
|
* Whether the TUI may request terminal mouse reporting (wheel scrolling).
|
|
39
39
|
*
|
|
40
|
-
* Default
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* Default ON (opencode parity): the wheel scrolls the chat out of the box.
|
|
41
|
+
* Trade-off, stated plainly: with button reporting enabled the terminal
|
|
42
|
+
* routes plain selection clicks and right-clicks to the app (which drops
|
|
43
|
+
* them), so select-to-copy and right-click paste need Shift+drag /
|
|
44
|
+
* Shift+right-click in most terminals instead. Set `KLYRO_MOUSE=0` to opt
|
|
45
|
+
* out (fully native selection) and scroll with PgUp/PgDn, Ctrl+U/D,
|
|
46
|
+
* Shift+↑/↓, Home/End, or Space instead.
|
|
45
47
|
*/
|
|
46
48
|
export declare function isMouseReportingEnabled(env?: Readonly<Record<string, string | undefined>>): boolean;
|
|
47
49
|
export declare const PASTE_START = "\u001B[200~";
|
package/dist/tui/mouse.js
CHANGED
|
@@ -76,6 +76,7 @@ export class MouseFilter {
|
|
|
76
76
|
const tail = n - i;
|
|
77
77
|
if (tail <= 5) {
|
|
78
78
|
const rest = buf.subarray(i).toString('latin1');
|
|
79
|
+
// eslint-disable-next-line no-control-regex -- intentional: detecting split SGR/X10 mouse-sequence prefixes
|
|
79
80
|
if (/^\x1b\[<$/.test(rest) || /^\x1b\[<[\d;]+$/.test(rest) || /^\x1b\[M.{0,2}$/.test(rest)) {
|
|
80
81
|
this.pending = buf.subarray(i);
|
|
81
82
|
break;
|
|
@@ -95,14 +96,16 @@ export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
|
|
|
95
96
|
/**
|
|
96
97
|
* Whether the TUI may request terminal mouse reporting (wheel scrolling).
|
|
97
98
|
*
|
|
98
|
-
* Default
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
99
|
+
* Default ON (opencode parity): the wheel scrolls the chat out of the box.
|
|
100
|
+
* Trade-off, stated plainly: with button reporting enabled the terminal
|
|
101
|
+
* routes plain selection clicks and right-clicks to the app (which drops
|
|
102
|
+
* them), so select-to-copy and right-click paste need Shift+drag /
|
|
103
|
+
* Shift+right-click in most terminals instead. Set `KLYRO_MOUSE=0` to opt
|
|
104
|
+
* out (fully native selection) and scroll with PgUp/PgDn, Ctrl+U/D,
|
|
105
|
+
* Shift+↑/↓, Home/End, or Space instead.
|
|
103
106
|
*/
|
|
104
107
|
export function isMouseReportingEnabled(env = process.env) {
|
|
105
|
-
return env.KLYRO_MOUSE
|
|
108
|
+
return env.KLYRO_MOUSE !== '0';
|
|
106
109
|
}
|
|
107
110
|
export const PASTE_START = '\x1b[200~';
|
|
108
111
|
export const PASTE_END = '\x1b[201~';
|
|
@@ -34,7 +34,6 @@ describe('scroll flow diagnostics', () => {
|
|
|
34
34
|
const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true }));
|
|
35
35
|
await tick(50);
|
|
36
36
|
const frame = lastFrame() ?? '';
|
|
37
|
-
// eslint-disable-next-line no-console
|
|
38
37
|
console.log(`[diag] frame rows=${rowsOf(frame)} cols~${(frame.split('\n')[0] ?? '').length}`);
|
|
39
38
|
expect(rowsOf(frame)).toBeLessThanOrEqual(32);
|
|
40
39
|
});
|
|
@@ -32,3 +32,17 @@ export interface TranscriptKey {
|
|
|
32
32
|
* Ctrl+Home → first · Ctrl+End → last · anything else → undefined.
|
|
33
33
|
*/
|
|
34
34
|
export declare function getTranscriptCommand(input: string, key: TranscriptKey): TranscriptCommand | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* 4.4c — `# note` command: a line starting with `# ` (hash + space) is a
|
|
37
|
+
* session note, not a prompt. Returns the note text, or undefined when the
|
|
38
|
+
* line is not a note (a lone `#` or `#nospace` stays a regular prompt).
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseSessionNote(line: string): string | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Handle one input line as a potential `# note`. When it is a note, the
|
|
43
|
+
* text is appended as session-note feedback through `append` (the same
|
|
44
|
+
* channel as other command feedback — the caller's queuedAppend) and
|
|
45
|
+
* persisted via `persist` when the caller has a session-file hook.
|
|
46
|
+
* Returns true when the line was consumed as a note.
|
|
47
|
+
*/
|
|
48
|
+
export declare function handleHashNoteLine(line: string, append: (text: string) => void, persist?: (note: string) => void | Promise<void>): boolean;
|
|
@@ -30,3 +30,34 @@ export function getTranscriptCommand(input, key) {
|
|
|
30
30
|
return 'messages_last';
|
|
31
31
|
return undefined;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* 4.4c — `# note` command: a line starting with `# ` (hash + space) is a
|
|
35
|
+
* session note, not a prompt. Returns the note text, or undefined when the
|
|
36
|
+
* line is not a note (a lone `#` or `#nospace` stays a regular prompt).
|
|
37
|
+
*/
|
|
38
|
+
export function parseSessionNote(line) {
|
|
39
|
+
if (!line.startsWith('# '))
|
|
40
|
+
return undefined;
|
|
41
|
+
const note = line.slice(2).trim();
|
|
42
|
+
if (!note)
|
|
43
|
+
return undefined;
|
|
44
|
+
return note;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Handle one input line as a potential `# note`. When it is a note, the
|
|
48
|
+
* text is appended as session-note feedback through `append` (the same
|
|
49
|
+
* channel as other command feedback — the caller's queuedAppend) and
|
|
50
|
+
* persisted via `persist` when the caller has a session-file hook.
|
|
51
|
+
* Returns true when the line was consumed as a note.
|
|
52
|
+
*/
|
|
53
|
+
export function handleHashNoteLine(line, append, persist) {
|
|
54
|
+
const note = parseSessionNote(line);
|
|
55
|
+
if (note === undefined)
|
|
56
|
+
return false;
|
|
57
|
+
append(`[note] ${note}`);
|
|
58
|
+
try {
|
|
59
|
+
void persist?.(note);
|
|
60
|
+
}
|
|
61
|
+
catch { /* best-effort only */ }
|
|
62
|
+
return true;
|
|
63
|
+
}
|
package/dist/util/log.d.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 1.5
|
|
3
|
-
*
|
|
2
|
+
* Structured logging (1.5): pino JSON logs with secret redaction.
|
|
3
|
+
*
|
|
4
|
+
* - Destination: `KLYRO_LOG_DIR/klyro.log` (falls back to `~/.klyro`), with
|
|
5
|
+
* the same 2 MiB rotate-to-`.1` cap as the TUI debug log.
|
|
6
|
+
* - Level from `KLYRO_LOG_LEVEL` (or `DEBUG`→debug); unknown values fall
|
|
7
|
+
* back to `info` instead of throwing.
|
|
8
|
+
* - Every string is passed through the secret redactor first, and pino's
|
|
9
|
+
* own `redact` paths cover common credential object shapes.
|
|
10
|
+
* - Under VITEST the logger is silent (level `silent`) so tests never touch
|
|
11
|
+
* the real log file.
|
|
12
|
+
* - The logger never throws: an unwritable destination degrades to silent.
|
|
4
13
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
export declare function
|
|
12
|
-
export declare function
|
|
14
|
+
import { type Logger } from 'pino';
|
|
15
|
+
/** Shared process logger. Never throws; silent when logging is unavailable. */
|
|
16
|
+
export declare function getLogger(): Logger;
|
|
17
|
+
/** For tests: drop the cached instance so env changes take effect. */
|
|
18
|
+
export declare function resetLoggerForTests(): void;
|
|
19
|
+
/** Log helpers that redact string payloads before they reach pino. */
|
|
20
|
+
export declare function logInfo(msg: string, data?: unknown): void;
|
|
21
|
+
export declare function logDebug(msg: string, data?: unknown): void;
|
package/dist/util/log.js
CHANGED
|
@@ -1,75 +1,110 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 1.5
|
|
3
|
-
*
|
|
2
|
+
* Structured logging (1.5): pino JSON logs with secret redaction.
|
|
3
|
+
*
|
|
4
|
+
* - Destination: `KLYRO_LOG_DIR/klyro.log` (falls back to `~/.klyro`), with
|
|
5
|
+
* the same 2 MiB rotate-to-`.1` cap as the TUI debug log.
|
|
6
|
+
* - Level from `KLYRO_LOG_LEVEL` (or `DEBUG`→debug); unknown values fall
|
|
7
|
+
* back to `info` instead of throwing.
|
|
8
|
+
* - Every string is passed through the secret redactor first, and pino's
|
|
9
|
+
* own `redact` paths cover common credential object shapes.
|
|
10
|
+
* - Under VITEST the logger is silent (level `silent`) so tests never touch
|
|
11
|
+
* the real log file.
|
|
12
|
+
* - The logger never throws: an unwritable destination degrades to silent.
|
|
4
13
|
*/
|
|
5
|
-
import * as fs from 'node:fs
|
|
6
|
-
import * as fsSync from 'node:fs';
|
|
7
|
-
import * as path from 'node:path';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
8
15
|
import * as os from 'node:os';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import pino from 'pino';
|
|
9
18
|
import { redact } from '../policy/secret-redactor.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
const ROTATE_BYTES = 2 * 1024 * 1024;
|
|
20
|
+
const LEVELS = new Set(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']);
|
|
21
|
+
function level() {
|
|
22
|
+
if (process.env.VITEST)
|
|
23
|
+
return 'silent';
|
|
24
|
+
const raw = (process.env.KLYRO_LOG_LEVEL ?? (process.env.DEBUG ? 'debug' : 'info')).toLowerCase();
|
|
25
|
+
return (LEVELS.has(raw) ? raw : 'info');
|
|
15
26
|
}
|
|
16
|
-
function
|
|
17
|
-
const dir =
|
|
18
|
-
|
|
19
|
-
return path.join(dir, `klyro-${date}.log`);
|
|
27
|
+
function logFile() {
|
|
28
|
+
const dir = process.env.KLYRO_LOG_DIR ?? path.join(os.homedir() || process.cwd(), '.klyro');
|
|
29
|
+
return path.join(dir, 'klyro.log');
|
|
20
30
|
}
|
|
21
|
-
|
|
22
|
-
const dir = getLogDir();
|
|
31
|
+
function rotateBestEffort(file) {
|
|
23
32
|
try {
|
|
24
|
-
|
|
25
|
-
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
|
|
26
|
-
for (const f of files) {
|
|
27
|
-
if (!f.startsWith('klyro-') || !f.endsWith('.log'))
|
|
28
|
-
continue;
|
|
29
|
-
const fp = path.join(dir, f);
|
|
33
|
+
if (fs.statSync(file).size > ROTATE_BYTES) {
|
|
30
34
|
try {
|
|
31
|
-
|
|
32
|
-
if (st.mtimeMs < cutoff)
|
|
33
|
-
await fs.unlink(fp);
|
|
35
|
+
fs.rmSync(`${file}.1`, { force: true });
|
|
34
36
|
}
|
|
35
37
|
catch { /* ignore */ }
|
|
38
|
+
fs.renameSync(file, `${file}.1`);
|
|
36
39
|
}
|
|
37
40
|
}
|
|
38
|
-
catch { /*
|
|
41
|
+
catch { /* missing — fine */ }
|
|
39
42
|
}
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
let cached = null;
|
|
44
|
+
/** Shared process logger. Never throws; silent when logging is unavailable. */
|
|
45
|
+
export function getLogger() {
|
|
46
|
+
if (cached)
|
|
47
|
+
return cached;
|
|
48
|
+
try {
|
|
49
|
+
// Tests must never touch the real log file: silent logger, no destination.
|
|
50
|
+
if (process.env.VITEST) {
|
|
51
|
+
cached = pino({ level: 'silent' });
|
|
52
|
+
return cached;
|
|
53
|
+
}
|
|
54
|
+
const file = logFile();
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
}
|
|
58
|
+
catch { /* ignore */ }
|
|
59
|
+
rotateBestEffort(file);
|
|
60
|
+
cached = pino({
|
|
61
|
+
level: level(),
|
|
62
|
+
redact: {
|
|
63
|
+
paths: ['apiKey', 'api_key', '*.apiKey', '*.api_key', '*.token', '*.password', '*.secret', 'headers.authorization', '*.authorization'],
|
|
64
|
+
censor: '[REDACTED]',
|
|
65
|
+
},
|
|
66
|
+
}, pino.destination({ dest: file, sync: true }));
|
|
45
67
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
catch {
|
|
69
|
+
try {
|
|
70
|
+
cached = pino({ level: 'silent' });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
cached = { info: () => undefined, debug: () => undefined, error: () => undefined, warn: () => undefined };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return cached;
|
|
77
|
+
}
|
|
78
|
+
/** For tests: drop the cached instance so env changes take effect. */
|
|
79
|
+
export function resetLoggerForTests() {
|
|
80
|
+
cached = null;
|
|
81
|
+
}
|
|
82
|
+
/** Log helpers that redact string payloads before they reach pino. */
|
|
83
|
+
export function logInfo(msg, data) {
|
|
50
84
|
try {
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
if (data === undefined)
|
|
86
|
+
getLogger().info(redact(msg));
|
|
87
|
+
else
|
|
88
|
+
getLogger().info(redactStructured(data), redact(msg));
|
|
53
89
|
}
|
|
54
|
-
catch { /*
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
90
|
+
catch { /* logging never breaks the run */ }
|
|
91
|
+
}
|
|
92
|
+
export function logDebug(msg, data) {
|
|
93
|
+
try {
|
|
94
|
+
if (data === undefined)
|
|
95
|
+
getLogger().debug(redact(msg));
|
|
59
96
|
else
|
|
60
|
-
|
|
97
|
+
getLogger().debug(redactStructured(data), redact(msg));
|
|
61
98
|
}
|
|
99
|
+
catch { /* logging never breaks the run */ }
|
|
62
100
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const file = getLogFile();
|
|
101
|
+
function redactStructured(data) {
|
|
102
|
+
if (typeof data === 'string')
|
|
103
|
+
return redact(data);
|
|
67
104
|
try {
|
|
68
|
-
|
|
69
|
-
fsSync.appendFileSync(file, line, 'utf-8');
|
|
105
|
+
return JSON.parse(redact(JSON.stringify(data)));
|
|
70
106
|
}
|
|
71
|
-
catch {
|
|
72
|
-
|
|
73
|
-
process.stderr.write(`[${entry.level}] ${redactedMsg}\n`);
|
|
107
|
+
catch {
|
|
108
|
+
return '[unserializable]';
|
|
74
109
|
}
|
|
75
110
|
}
|