klyro 1.0.17 → 1.0.20
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/runtime.d.ts +7 -0
- package/dist/agent/runtime.js +43 -1
- package/dist/checkpoints/store.d.ts +28 -1
- package/dist/checkpoints/store.js +46 -2
- package/dist/cli/args.d.ts +7 -0
- package/dist/cli/args.js +10 -0
- package/dist/cli/eval.d.ts +32 -0
- package/dist/cli/eval.js +82 -3
- package/dist/cli/repl.js +16 -2
- package/dist/cli/run.d.ts +5 -0
- package/dist/cli/run.js +4 -0
- package/dist/context/klyro-md.d.ts +11 -0
- package/dist/context/klyro-md.js +59 -4
- 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 +40 -2
- package/dist/index.js +5 -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.d.ts +50 -0
- package/dist/tools/lsp/diagnostics.js +109 -3
- package/dist/tools/registry.js +7 -1
- package/dist/tools/search/glob.js +8 -3
- package/dist/tools/search/grep.js +10 -3
- package/dist/tools/search/ignore.d.ts +8 -0
- package/dist/tools/search/ignore.js +49 -0
- package/dist/tools/search/search-files.js +10 -3
- package/dist/tui/app.js +37 -1
- package/dist/tui/app.test.js +17 -3
- package/dist/tui/transcript-commands.d.ts +14 -0
- package/dist/tui/transcript-commands.js +31 -0
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
8
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe } from '../normalize.js';
|
|
10
|
+
import { IgnoreFilter } from './ignore.js';
|
|
10
11
|
const InputSchema = z.object({
|
|
11
12
|
pattern: z.string().min(1).describe('Glob pattern, e.g. "**/*.ts" or "src/**/*.json"'),
|
|
12
13
|
cwd: z.string().optional().describe('Base directory; defaults to workspace cwd'),
|
|
@@ -25,7 +26,8 @@ export const globTool = defineTool({
|
|
|
25
26
|
const max = input.maxResults ?? DEFAULT_MAX;
|
|
26
27
|
const matches = [];
|
|
27
28
|
let truncated = false;
|
|
28
|
-
|
|
29
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
30
|
+
await walk(base, base, matcher, max + 1, matches, () => (truncated = true), ignore, ctx.cwd);
|
|
29
31
|
// Normalize to relative, forward-slash paths from ctx.cwd (so
|
|
30
32
|
// tool inputs/outputs are stable across Windows and POSIX).
|
|
31
33
|
const rel = matches.slice(0, max).map((m) => path.relative(ctx.cwd, m).split(path.sep).join('/'));
|
|
@@ -50,7 +52,7 @@ export function globToRegex(pattern) {
|
|
|
50
52
|
.replace(/::DOUBLESTAR::/g, '.*');
|
|
51
53
|
return new RegExp(`${anchor}${escaped}$`);
|
|
52
54
|
}
|
|
53
|
-
async function walk(root, dir, matcher, cap, out, onCap) {
|
|
55
|
+
async function walk(root, dir, matcher, cap, out, onCap, ignore, cwd) {
|
|
54
56
|
if (out.length >= cap)
|
|
55
57
|
return;
|
|
56
58
|
let names;
|
|
@@ -66,6 +68,9 @@ async function walk(root, dir, matcher, cap, out, onCap) {
|
|
|
66
68
|
if (SKIP_DIRS.has(name))
|
|
67
69
|
continue;
|
|
68
70
|
const full = path.join(dir, name);
|
|
71
|
+
const relToCwd = path.relative(cwd, full);
|
|
72
|
+
if (ignore.ignores(relToCwd))
|
|
73
|
+
continue;
|
|
69
74
|
let isDir = false;
|
|
70
75
|
try {
|
|
71
76
|
const s = await fs.lstat(full);
|
|
@@ -83,6 +88,6 @@ async function walk(root, dir, matcher, cap, out, onCap) {
|
|
|
83
88
|
onCap();
|
|
84
89
|
}
|
|
85
90
|
if (isDir)
|
|
86
|
-
await walk(root, full, matcher, cap, out, onCap);
|
|
91
|
+
await walk(root, full, matcher, cap, out, onCap, ignore, cwd);
|
|
87
92
|
}
|
|
88
93
|
}
|
|
@@ -7,6 +7,7 @@ import { z } from 'zod';
|
|
|
7
7
|
import { defineTool } from '../types.js';
|
|
8
8
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
9
9
|
import { safe, TOOL_ERROR_CODES } from '../normalize.js';
|
|
10
|
+
import { IgnoreFilter } from './ignore.js';
|
|
10
11
|
const InputSchema = z.object({
|
|
11
12
|
pattern: z.string().min(1).describe('JavaScript regular expression (not ripgrep syntax)'),
|
|
12
13
|
cwd: z.string().optional(),
|
|
@@ -48,6 +49,7 @@ export const grepTool = defineTool({
|
|
|
48
49
|
const hits = [];
|
|
49
50
|
let searched = 0;
|
|
50
51
|
let truncated = false;
|
|
52
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
51
53
|
await walk(base, async (file) => {
|
|
52
54
|
if (hits.length >= max) {
|
|
53
55
|
truncated = true;
|
|
@@ -88,7 +90,7 @@ export const grepTool = defineTool({
|
|
|
88
90
|
hits.push(hit);
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
|
-
});
|
|
93
|
+
}, ignore, ctx.cwd);
|
|
92
94
|
return { pattern: input.pattern, hits, truncated, searchedFiles: searched };
|
|
93
95
|
});
|
|
94
96
|
},
|
|
@@ -102,7 +104,7 @@ function globToRegex(g) {
|
|
|
102
104
|
.replace(/::DS::/g, '.*');
|
|
103
105
|
return new RegExp(`^${escaped}$`);
|
|
104
106
|
}
|
|
105
|
-
async function walk(dir, visit) {
|
|
107
|
+
async function walk(dir, visit, ignore, cwd) {
|
|
106
108
|
let names;
|
|
107
109
|
try {
|
|
108
110
|
names = await fs.readdir(dir);
|
|
@@ -114,6 +116,11 @@ async function walk(dir, visit) {
|
|
|
114
116
|
if (SKIP_DIRS.has(name))
|
|
115
117
|
continue;
|
|
116
118
|
const full = path.join(dir, name);
|
|
119
|
+
if (ignore && cwd) {
|
|
120
|
+
const relToCwd = path.relative(cwd, full);
|
|
121
|
+
if (ignore.ignores(relToCwd))
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
117
124
|
let isDir = false;
|
|
118
125
|
try {
|
|
119
126
|
const s = await fs.lstat(full);
|
|
@@ -125,7 +132,7 @@ async function walk(dir, visit) {
|
|
|
125
132
|
continue;
|
|
126
133
|
}
|
|
127
134
|
if (isDir)
|
|
128
|
-
await walk(full, visit);
|
|
135
|
+
await walk(full, visit, ignore, cwd);
|
|
129
136
|
else
|
|
130
137
|
await visit(full);
|
|
131
138
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Converts a .gitignore rule to a regex. Assumes no negative (!) rules for now. */
|
|
2
|
+
export declare function ignoreRuleToRegex(rule: string): RegExp | null;
|
|
3
|
+
export declare class IgnoreFilter {
|
|
4
|
+
private rules;
|
|
5
|
+
static load(cwd: string): Promise<IgnoreFilter>;
|
|
6
|
+
/** Accepts a relative path (posix or windows) and returns true if it should be ignored. */
|
|
7
|
+
ignores(relPath: string): boolean;
|
|
8
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
/** Converts a .gitignore rule to a regex. Assumes no negative (!) rules for now. */
|
|
4
|
+
export function ignoreRuleToRegex(rule) {
|
|
5
|
+
let p = rule.trim();
|
|
6
|
+
if (!p || p.startsWith('#') || p.startsWith('!'))
|
|
7
|
+
return null;
|
|
8
|
+
// Strip trailing slashes
|
|
9
|
+
if (p.endsWith('/'))
|
|
10
|
+
p = p.slice(0, -1);
|
|
11
|
+
// If it starts with /, anchor to root; else it can match anywhere
|
|
12
|
+
const anchorStart = p.startsWith('/') ? '^' : '(?:^|/)';
|
|
13
|
+
if (p.startsWith('/'))
|
|
14
|
+
p = p.slice(1);
|
|
15
|
+
const escaped = p
|
|
16
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
17
|
+
.replace(/\*\*/g, '::DS::')
|
|
18
|
+
.replace(/\*/g, '[^/]*')
|
|
19
|
+
.replace(/\?/g, '[^/]')
|
|
20
|
+
.replace(/::DS::/g, '.*');
|
|
21
|
+
return new RegExp(`${anchorStart}${escaped}(?:/|$)`);
|
|
22
|
+
}
|
|
23
|
+
export class IgnoreFilter {
|
|
24
|
+
rules = [];
|
|
25
|
+
static async load(cwd) {
|
|
26
|
+
const filter = new IgnoreFilter();
|
|
27
|
+
for (const name of ['.gitignore', '.klyroignore']) {
|
|
28
|
+
try {
|
|
29
|
+
const content = await fs.readFile(path.join(cwd, name), 'utf-8');
|
|
30
|
+
for (const line of content.split('\n')) {
|
|
31
|
+
const re = ignoreRuleToRegex(line);
|
|
32
|
+
if (re)
|
|
33
|
+
filter.rules.push(re);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* missing/unreadable */ }
|
|
37
|
+
}
|
|
38
|
+
return filter;
|
|
39
|
+
}
|
|
40
|
+
/** Accepts a relative path (posix or windows) and returns true if it should be ignored. */
|
|
41
|
+
ignores(relPath) {
|
|
42
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
43
|
+
for (const re of this.rules) {
|
|
44
|
+
if (re.test(normalized))
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -16,6 +16,7 @@ import { defineTool } from '../types.js';
|
|
|
16
16
|
import { resolveWithinCwd } from '../../policy/path-guard.js';
|
|
17
17
|
import { safe } from '../normalize.js';
|
|
18
18
|
import { globToRegex } from './glob.js';
|
|
19
|
+
import { IgnoreFilter } from './ignore.js';
|
|
19
20
|
const InputSchema = z.object({
|
|
20
21
|
query: z.string().optional().describe('Substring or regex to match against file paths (case-insensitive)'),
|
|
21
22
|
glob: z.string().optional().describe('Glob pattern to constrain candidates, e.g. "src/**/*.ts"'),
|
|
@@ -29,7 +30,7 @@ function queryToRegex(query) {
|
|
|
29
30
|
const escaped = query.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
30
31
|
return new RegExp(escaped, 'i');
|
|
31
32
|
}
|
|
32
|
-
async function walk(root, dir, onFile) {
|
|
33
|
+
async function walk(root, dir, onFile, ignore, cwd) {
|
|
33
34
|
let names;
|
|
34
35
|
try {
|
|
35
36
|
names = await fs.readdir(dir);
|
|
@@ -41,6 +42,11 @@ async function walk(root, dir, onFile) {
|
|
|
41
42
|
if (SKIP_DIRS.has(name))
|
|
42
43
|
continue;
|
|
43
44
|
const full = path.join(dir, name);
|
|
45
|
+
if (ignore && cwd) {
|
|
46
|
+
const relToCwd = path.relative(cwd, full);
|
|
47
|
+
if (ignore.ignores(relToCwd))
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
44
50
|
let isDir = false;
|
|
45
51
|
try {
|
|
46
52
|
const s = await fs.lstat(full);
|
|
@@ -52,7 +58,7 @@ async function walk(root, dir, onFile) {
|
|
|
52
58
|
continue;
|
|
53
59
|
}
|
|
54
60
|
if (isDir)
|
|
55
|
-
await walk(root, full, onFile);
|
|
61
|
+
await walk(root, full, onFile, ignore, cwd);
|
|
56
62
|
else
|
|
57
63
|
await onFile(full);
|
|
58
64
|
}
|
|
@@ -69,6 +75,7 @@ export const searchFilesTool = defineTool({
|
|
|
69
75
|
const globRe = input.glob ? globToRegex(input.glob) : null;
|
|
70
76
|
const candidates = [];
|
|
71
77
|
const now = Date.now();
|
|
78
|
+
const ignore = await IgnoreFilter.load(ctx.cwd);
|
|
72
79
|
await walk(base, base, async (full) => {
|
|
73
80
|
const rel = path.relative(ctx.cwd, full).split(path.sep).join('/');
|
|
74
81
|
if (globRe && !globRe.test(rel))
|
|
@@ -101,7 +108,7 @@ export const searchFilesTool = defineTool({
|
|
|
101
108
|
// Recency: 0..30 points, 0 days = 30, 30+ days = 0
|
|
102
109
|
score += Math.max(0, 30 - ageDays);
|
|
103
110
|
candidates.push({ path: rel, score, mtimeMs, firstParty });
|
|
104
|
-
});
|
|
111
|
+
}, ignore, ctx.cwd);
|
|
105
112
|
candidates.sort((a, b) => b.score - a.score);
|
|
106
113
|
const truncated = candidates.length > max;
|
|
107
114
|
return { matches: candidates.slice(0, max), truncated };
|
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';
|
|
@@ -1287,5 +1323,5 @@ export function App(props) {
|
|
|
1287
1323
|
if (it.kind === 'diff')
|
|
1288
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));
|
|
1289
1325
|
return null;
|
|
1290
|
-
}) : 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' ? ' ●' : ''] })] })] }));
|
|
1291
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
|
});
|
|
@@ -757,6 +757,20 @@ describe('App', () => {
|
|
|
757
757
|
expect(f2() ?? '').toContain(readVersion());
|
|
758
758
|
expect(f2() ?? '').not.toContain('0.1.27');
|
|
759
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
|
+
});
|
|
760
774
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
761
775
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
762
776
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -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
|
+
}
|