klyro 0.1.46 → 0.1.48
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/registry.js +53 -2
- package/dist/cli/auth.d.ts +14 -0
- package/dist/cli/auth.js +65 -19
- package/dist/cli/config.d.ts +2 -1
- package/dist/cli/config.js +20 -0
- package/dist/cli/repl.js +130 -24
- package/dist/cli/setup.d.ts +23 -0
- package/dist/cli/setup.js +49 -0
- package/dist/providers.d.ts +14 -2
- package/dist/providers.js +92 -8
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +22 -3
- package/dist/tui/app.test.js +19 -0
- package/dist/tui/mouse.d.ts +36 -0
- package/dist/tui/mouse.js +94 -0
- package/dist/tui/scroll-flow.test.d.ts +1 -0
- package/dist/tui/scroll-flow.test.js +94 -0
- package/dist/tui/scroll-model.js +9 -6
- package/package.json +1 -1
package/dist/agent/registry.js
CHANGED
|
@@ -16,6 +16,9 @@
|
|
|
16
16
|
* The result is wrapped in retryingAdapter by default. Pass
|
|
17
17
|
* { retry: false } to skip.
|
|
18
18
|
*/
|
|
19
|
+
import * as fsSync from 'node:fs';
|
|
20
|
+
import * as os from 'node:os';
|
|
21
|
+
import * as path from 'node:path';
|
|
19
22
|
import { httpChatAdapter } from './provider-adapter.js';
|
|
20
23
|
import { anthropicAdapter } from './anthropic-adapter.js';
|
|
21
24
|
import { retryingAdapter } from './retry.js';
|
|
@@ -63,9 +66,57 @@ function normalizeProviderName(name) {
|
|
|
63
66
|
return lower;
|
|
64
67
|
return PROVIDER_ALIASES[lower];
|
|
65
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Persisted provider settings (sync read — for CLIs that must not go async).
|
|
71
|
+
* Env still wins; ~/.klyro/settings.json + credentials are the fallback so
|
|
72
|
+
* `klyro run` works in fresh terminals with zero env vars.
|
|
73
|
+
*
|
|
74
|
+
* Reads the files directly (no module imports) to avoid any import cycle
|
|
75
|
+
* between agent/ and cli/ layers.
|
|
76
|
+
*/
|
|
77
|
+
function persistedProviderSettings() {
|
|
78
|
+
try {
|
|
79
|
+
const home = process.env.KLYRO_CONFIG_DIR ?? (os.homedir() || process.cwd());
|
|
80
|
+
const cfgPaths = process.env.KLYRO_CONFIG
|
|
81
|
+
? [process.env.KLYRO_CONFIG]
|
|
82
|
+
: [path.join(home, '.klyro', 'settings.json'), path.join(home, '.klyro', 'config.json')];
|
|
83
|
+
let cfg = {};
|
|
84
|
+
for (const p of cfgPaths) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = JSON.parse(fsSync.readFileSync(p, 'utf-8'));
|
|
87
|
+
if (parsed && typeof parsed === 'object') {
|
|
88
|
+
cfg = parsed;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const credFile = process.env.KLYRO_CREDENTIALS_FILE ?? path.join(home, '.klyro', 'credentials.json');
|
|
97
|
+
let creds = {};
|
|
98
|
+
try {
|
|
99
|
+
creds = JSON.parse(fsSync.readFileSync(credFile, 'utf-8'));
|
|
100
|
+
}
|
|
101
|
+
catch { /* none */ }
|
|
102
|
+
const keyOf = (p) => typeof creds[p] === 'string' && creds[p].length > 0 ? creds[p] : undefined;
|
|
103
|
+
const baseURL = (cfg.baseUrl ?? cfg.baseURL);
|
|
104
|
+
const provider = cfg.provider;
|
|
105
|
+
const model = (cfg.model ?? cfg['model.default']);
|
|
106
|
+
const apiKey = (cfg.apiKey ?? cfg.api_key) ??
|
|
107
|
+
(provider ? keyOf(provider) : undefined) ??
|
|
108
|
+
keyOf('openai') ??
|
|
109
|
+
keyOf('anthropic');
|
|
110
|
+
return { baseURL, apiKey, provider, model };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
}
|
|
66
116
|
export function buildProvider(opts = {}) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
117
|
+
const saved = persistedProviderSettings();
|
|
118
|
+
const baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL ?? saved.baseURL;
|
|
119
|
+
const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY ?? saved.apiKey;
|
|
69
120
|
const timeoutMs = opts.timeoutMs ?? 60_000;
|
|
70
121
|
// Resolve provider (with aliases like 9router -> openrouter -> openai)
|
|
71
122
|
let provider;
|
package/dist/cli/auth.d.ts
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
* 2.2 — klyro login / logout / aliases
|
|
3
3
|
* Stores masked key → ~/.klyro/credentials.json 0600
|
|
4
4
|
*/
|
|
5
|
+
export declare function credPath(): string;
|
|
6
|
+
/** Persist one provider key (0600). Never logs or returns the key. */
|
|
7
|
+
export declare function saveKey(provider: string, key: string): Promise<void>;
|
|
8
|
+
/** Which providers have stored keys (names only — never values). */
|
|
9
|
+
export declare function storedProviders(): string[];
|
|
10
|
+
export declare const LOGIN_DEFAULTS: Record<string, {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
model: string;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* `klyro login` — full one-time setup. Persists provider + base URL + model
|
|
16
|
+
* to ~/.klyro/settings.json and the key to ~/.klyro/credentials.json (0600),
|
|
17
|
+
* so every future terminal picks them up with no env vars.
|
|
18
|
+
*/
|
|
5
19
|
export declare function runLogin(): Promise<number>;
|
|
6
20
|
export declare function runLogout(provider?: string): Promise<number>;
|
|
7
21
|
export declare function getStoredKey(provider: string): string | undefined;
|
package/dist/cli/auth.js
CHANGED
|
@@ -3,37 +3,82 @@
|
|
|
3
3
|
* Stores masked key → ~/.klyro/credentials.json 0600
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as fsSync from 'node:fs';
|
|
6
7
|
import * as path from 'node:path';
|
|
7
8
|
import * as os from 'node:os';
|
|
8
9
|
import * as readline from 'node:readline/promises';
|
|
9
10
|
import { stdin, stdout } from 'node:process';
|
|
10
|
-
function credPath() {
|
|
11
|
+
export function credPath() {
|
|
12
|
+
// KLYRO_CREDENTIALS_FILE override exists for tests; real users get ~/.klyro/credentials.json.
|
|
13
|
+
if (process.env.KLYRO_CREDENTIALS_FILE)
|
|
14
|
+
return process.env.KLYRO_CREDENTIALS_FILE;
|
|
11
15
|
const home = os.homedir() || process.cwd();
|
|
12
16
|
return path.join(home, '.klyro', 'credentials.json');
|
|
13
17
|
}
|
|
18
|
+
/** Persist one provider key (0600). Never logs or returns the key. */
|
|
19
|
+
export async function saveKey(provider, key) {
|
|
20
|
+
const creds = {};
|
|
21
|
+
try {
|
|
22
|
+
const raw = await fs.readFile(credPath(), 'utf-8');
|
|
23
|
+
Object.assign(creds, JSON.parse(raw));
|
|
24
|
+
}
|
|
25
|
+
catch { /* ignore */ }
|
|
26
|
+
creds[provider] = key.trim();
|
|
27
|
+
await fs.mkdir(path.dirname(credPath()), { recursive: true });
|
|
28
|
+
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
29
|
+
try {
|
|
30
|
+
await fs.chmod(credPath(), 0o600);
|
|
31
|
+
}
|
|
32
|
+
catch { /* ignore on Windows */ }
|
|
33
|
+
}
|
|
34
|
+
/** Which providers have stored keys (names only — never values). */
|
|
35
|
+
export function storedProviders() {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fsSync.readFileSync(credPath(), 'utf-8');
|
|
38
|
+
const creds = JSON.parse(raw);
|
|
39
|
+
return Object.keys(creds).filter((k) => typeof creds[k] === 'string' && creds[k].length > 0);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export const LOGIN_DEFAULTS = {
|
|
46
|
+
openai: { baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
|
47
|
+
anthropic: { baseUrl: 'https://api.anthropic.com/v1', model: 'claude-3-5-sonnet-20240620' },
|
|
48
|
+
local: { baseUrl: 'http://localhost:11434/v1', model: 'llama3.2' },
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* `klyro login` — full one-time setup. Persists provider + base URL + model
|
|
52
|
+
* to ~/.klyro/settings.json and the key to ~/.klyro/credentials.json (0600),
|
|
53
|
+
* so every future terminal picks them up with no env vars.
|
|
54
|
+
*/
|
|
14
55
|
export async function runLogin() {
|
|
15
56
|
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
16
57
|
try {
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
58
|
+
const providerRaw = (await rl.question('Provider (openai/anthropic/local) [openai]: ')) || 'openai';
|
|
59
|
+
const provider = providerRaw.trim().toLowerCase();
|
|
60
|
+
const defs = LOGIN_DEFAULTS[provider] ?? LOGIN_DEFAULTS.openai;
|
|
61
|
+
const keyPrompt = provider === 'local' ? 'API key (empty for local Ollama): ' : 'API key (paste): ';
|
|
62
|
+
const key = await rl.question(keyPrompt);
|
|
63
|
+
if (!key.trim() && provider !== 'local') {
|
|
20
64
|
process.stderr.write('No key provided\n');
|
|
21
65
|
return 2;
|
|
22
66
|
}
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
creds[provider] = key.trim();
|
|
30
|
-
await fs.mkdir(path.dirname(credPath()), { recursive: true });
|
|
31
|
-
await fs.writeFile(credPath(), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
32
|
-
try {
|
|
33
|
-
await fs.chmod(credPath(), 0o600);
|
|
67
|
+
const baseUrl = ((await rl.question(`Base URL [${defs.baseUrl}]: `)) || defs.baseUrl).trim();
|
|
68
|
+
const model = ((await rl.question(`Model [${defs.model}]: `)) || defs.model).trim();
|
|
69
|
+
const storeProvider = provider === 'local' ? 'openai' : provider;
|
|
70
|
+
if (key.trim()) {
|
|
71
|
+
await saveKey(storeProvider, key);
|
|
72
|
+
process.stdout.write(`Saved ${storeProvider} key to ${credPath()} (0600)\n`);
|
|
34
73
|
}
|
|
35
|
-
|
|
36
|
-
|
|
74
|
+
// Persist non-secret settings (merged with existing config, never clobbers).
|
|
75
|
+
const { loadConfig, saveConfig } = await import('./config.js');
|
|
76
|
+
const cfg = await loadConfig();
|
|
77
|
+
cfg.provider = storeProvider;
|
|
78
|
+
cfg.baseUrl = baseUrl;
|
|
79
|
+
cfg.model = model;
|
|
80
|
+
await saveConfig(cfg);
|
|
81
|
+
process.stdout.write(`Saved provider settings (applies to all terminals — no env vars needed)\n`);
|
|
37
82
|
return 0;
|
|
38
83
|
}
|
|
39
84
|
finally {
|
|
@@ -63,9 +108,10 @@ export async function runLogout(provider) {
|
|
|
63
108
|
}
|
|
64
109
|
export function getStoredKey(provider) {
|
|
65
110
|
try {
|
|
66
|
-
const raw =
|
|
111
|
+
const raw = fsSync.readFileSync(credPath(), 'utf-8');
|
|
67
112
|
const creds = JSON.parse(raw);
|
|
68
|
-
|
|
113
|
+
const v = creds[provider];
|
|
114
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
69
115
|
}
|
|
70
116
|
catch {
|
|
71
117
|
return undefined;
|
package/dist/cli/config.d.ts
CHANGED
|
@@ -7,8 +7,8 @@ import { z } from 'zod';
|
|
|
7
7
|
export declare const ConfigSchema: z.ZodObject<{
|
|
8
8
|
model: z.ZodOptional<z.ZodString>;
|
|
9
9
|
provider: z.ZodOptional<z.ZodEnum<{
|
|
10
|
-
anthropic: "anthropic";
|
|
11
10
|
openai: "openai";
|
|
11
|
+
anthropic: "anthropic";
|
|
12
12
|
}>>;
|
|
13
13
|
baseUrl: z.ZodOptional<z.ZodString>;
|
|
14
14
|
apiKey: z.ZodOptional<z.ZodString>;
|
|
@@ -31,6 +31,7 @@ declare function setByPath(obj: Record<string, unknown>, dotted: string, value:
|
|
|
31
31
|
declare function deleteByPath(obj: Record<string, unknown>, dotted: string): boolean;
|
|
32
32
|
declare function parseValue(raw: string): unknown;
|
|
33
33
|
export declare function loadConfig(): Promise<Record<string, unknown>>;
|
|
34
|
+
export declare function loadConfigSync(): Record<string, unknown>;
|
|
34
35
|
export declare function loadMergedConfig(cwd?: string, flags?: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
35
36
|
export declare function saveConfig(obj: Record<string, unknown>): Promise<void>;
|
|
36
37
|
export declare function runConfig(args: string[]): Promise<number>;
|
package/dist/cli/config.js
CHANGED
|
@@ -217,6 +217,26 @@ export async function loadConfig() {
|
|
|
217
217
|
}
|
|
218
218
|
return {};
|
|
219
219
|
}
|
|
220
|
+
// --- Synchronous single-file load (for sync call sites like buildProvider) ---
|
|
221
|
+
export function loadConfigSync() {
|
|
222
|
+
const files = [getConfigPath(), getLegacyConfigPath()];
|
|
223
|
+
for (const file of files) {
|
|
224
|
+
try {
|
|
225
|
+
const raw = fsSync.readFileSync(file, 'utf-8');
|
|
226
|
+
const obj = parseJsonc(raw, file);
|
|
227
|
+
return validateConfig(obj, file);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const e = err;
|
|
231
|
+
if (e.code === 'ENOENT')
|
|
232
|
+
continue;
|
|
233
|
+
// Corrupt config must not crash provider resolution — ignore here
|
|
234
|
+
// (klyro config/doctor surface the error properly).
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return {};
|
|
239
|
+
}
|
|
220
240
|
// --- Merged load with 5-layer precedence ---
|
|
221
241
|
export async function loadMergedConfig(cwd = process.cwd(), flags = {}) {
|
|
222
242
|
const layers = [];
|
package/dist/cli/repl.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* via the global hooks installed by App.useEffect.
|
|
7
7
|
*/
|
|
8
8
|
import React from 'react';
|
|
9
|
+
import * as fsSync from 'node:fs';
|
|
10
|
+
import * as nodePath from 'node:path';
|
|
9
11
|
import { render } from 'ink';
|
|
10
12
|
import { App } from '../tui/app.js';
|
|
11
13
|
import { httpChatAdapter } from '../agent/provider-adapter.js';
|
|
@@ -19,6 +21,7 @@ import { TuiApprovalBridge } from '../tui/approval.js';
|
|
|
19
21
|
import { parseUnifiedDiff } from '../tui/diff-parser.js';
|
|
20
22
|
import { parse } from './slash/parser.js';
|
|
21
23
|
import { resolveProvider, providerHelp } from '../providers.js';
|
|
24
|
+
import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE } from '../tui/mouse.js';
|
|
22
25
|
import { inferProviderFromBaseURL } from '../agent/registry.js';
|
|
23
26
|
import { getDefaultSessionStore } from '../persistence/session.js';
|
|
24
27
|
import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
|
|
@@ -27,16 +30,35 @@ export async function startRepl(opts = {}) {
|
|
|
27
30
|
// Reuse the same provider resolution as legacy repl.ts — probes local
|
|
28
31
|
// Ollama / LM Studio / vLLM when env is not fully set, so bare `klyro`
|
|
29
32
|
// works with a local model just like `klyro chat` does.
|
|
30
|
-
|
|
33
|
+
let resolved = await resolveProvider();
|
|
31
34
|
if (!resolved) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
process.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
// First-run setup: ask ONCE (stdin is free — the Ink App is not mounted
|
|
36
|
+
// yet), persist to ~/.klyro, and continue. Every future terminal — this
|
|
37
|
+
// one, new windows, `klyro run` — picks it up with zero env vars.
|
|
38
|
+
const interactive = !opts.nonInteractive && (opts.forceTty || process.stdin.isTTY);
|
|
39
|
+
if (interactive) {
|
|
40
|
+
const { runFirstRunSetup } = await import('./setup.js');
|
|
41
|
+
const readline = await import('node:readline/promises');
|
|
42
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
try {
|
|
44
|
+
const ans = await runFirstRunSetup((q) => rl.question(q));
|
|
45
|
+
if (!ans) {
|
|
46
|
+
process.stderr.write('klyro: setup aborted — run `klyro login` when ready.\n');
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
process.stderr.write('klyro: saved — this and all future terminals will use it (change via /provider /model or `klyro config`).\n');
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
resolved = await resolveProvider();
|
|
55
|
+
}
|
|
56
|
+
if (!resolved) {
|
|
57
|
+
process.stderr.write('klyro: no provider available.\n');
|
|
58
|
+
process.stderr.write(` ${providerHelp(null)}\n`);
|
|
59
|
+
process.stderr.write(' Run `klyro login` once (persists for all terminals), set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
|
|
60
|
+
return 2;
|
|
61
|
+
}
|
|
40
62
|
}
|
|
41
63
|
const baseUrl = resolved.baseURL;
|
|
42
64
|
const apiKey = resolved.apiKey;
|
|
@@ -139,6 +161,7 @@ export async function startRepl(opts = {}) {
|
|
|
139
161
|
try {
|
|
140
162
|
process.stdout.write('\x1b[?1049h\x1b[?25l'); // alt screen + hide cursor
|
|
141
163
|
process.stdout.write('\x1b[H\x1b[2J'); // home + clear
|
|
164
|
+
process.stdout.write(MOUSE_ENABLE); // wheel events (SGR), see tui/mouse.ts
|
|
142
165
|
}
|
|
143
166
|
catch { /* ignore */ }
|
|
144
167
|
};
|
|
@@ -146,10 +169,45 @@ export async function startRepl(opts = {}) {
|
|
|
146
169
|
if (!isAltScreen)
|
|
147
170
|
return;
|
|
148
171
|
try {
|
|
172
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
149
173
|
process.stdout.write('\x1b[?25h\x1b[?1049l'); // show cursor + leave alt
|
|
150
174
|
}
|
|
151
175
|
catch { /* ignore */ }
|
|
152
176
|
};
|
|
177
|
+
// OpenCode-style wheel scrolling (§8.5, S8): Ink owns stdin and cannot see
|
|
178
|
+
// mouse events, so tap stdin.emit — wheel deltas drive the App's scroll
|
|
179
|
+
// hooks, everything else passes through to Ink untouched.
|
|
180
|
+
const mouseFilter = new MouseFilter();
|
|
181
|
+
const origStdinEmit = process.stdin.emit.bind(process.stdin);
|
|
182
|
+
let mouseTapInstalled = false;
|
|
183
|
+
function installMouseTap() {
|
|
184
|
+
if (!isAltScreen || mouseTapInstalled)
|
|
185
|
+
return;
|
|
186
|
+
mouseTapInstalled = true;
|
|
187
|
+
process.stdin.emit = (...a) => {
|
|
188
|
+
if (a[0] === 'data' && Buffer.isBuffer(a[1])) {
|
|
189
|
+
const split = mouseFilter.push(a[1]);
|
|
190
|
+
for (const w of split.wheels) {
|
|
191
|
+
try {
|
|
192
|
+
if (isMounted && directHooks)
|
|
193
|
+
directHooks.scrollLines(w);
|
|
194
|
+
}
|
|
195
|
+
catch { /* ignore */ }
|
|
196
|
+
}
|
|
197
|
+
if (split.kept.length === 0)
|
|
198
|
+
return false;
|
|
199
|
+
a[1] = split.kept;
|
|
200
|
+
}
|
|
201
|
+
return origStdinEmit(...a);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function removeMouseTap() {
|
|
205
|
+
if (!mouseTapInstalled)
|
|
206
|
+
return;
|
|
207
|
+
mouseTapInstalled = false;
|
|
208
|
+
mouseFilter.reset();
|
|
209
|
+
process.stdin.emit = origStdinEmit;
|
|
210
|
+
}
|
|
153
211
|
// Declare app before handler to avoid TDZ; handler added after render
|
|
154
212
|
let app;
|
|
155
213
|
let sigintHandler;
|
|
@@ -178,12 +236,10 @@ export async function startRepl(opts = {}) {
|
|
|
178
236
|
if (consoleRing.length > 200)
|
|
179
237
|
consoleRing.splice(0, consoleRing.length - 200);
|
|
180
238
|
try {
|
|
181
|
-
const fs = require('node:fs');
|
|
182
|
-
const path = require('node:path');
|
|
183
239
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
|
|
184
|
-
const dir =
|
|
185
|
-
|
|
186
|
-
|
|
240
|
+
const dir = nodePath.join(home, '.klyro');
|
|
241
|
+
fsSync.mkdirSync(dir, { recursive: true });
|
|
242
|
+
fsSync.appendFileSync(nodePath.join(dir, 'debug.log'), line + '\n');
|
|
187
243
|
}
|
|
188
244
|
catch { /* ignore */ }
|
|
189
245
|
};
|
|
@@ -201,7 +257,22 @@ export async function startRepl(opts = {}) {
|
|
|
201
257
|
console.debug = origConsoleFns.debug;
|
|
202
258
|
}
|
|
203
259
|
patchConsole();
|
|
260
|
+
installMouseTap();
|
|
204
261
|
const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
|
|
262
|
+
// Persisted provider settings: /model /provider write through to
|
|
263
|
+
// ~/.klyro/settings.json so new terminals inherit them. Touch flags keep
|
|
264
|
+
// /reload from clobbering explicit in-session switches.
|
|
265
|
+
let modelTouched = false;
|
|
266
|
+
let providerTouched = false;
|
|
267
|
+
async function persistProviderPatch(patch) {
|
|
268
|
+
try {
|
|
269
|
+
const { loadConfig, saveConfig } = await import('./config.js');
|
|
270
|
+
const cfg = await loadConfig();
|
|
271
|
+
Object.assign(cfg, patch);
|
|
272
|
+
await saveConfig(cfg);
|
|
273
|
+
}
|
|
274
|
+
catch { /* best-effort */ }
|
|
275
|
+
}
|
|
205
276
|
// P1 session/permission state (commands.md Priority 1)
|
|
206
277
|
let sessionLabel = '';
|
|
207
278
|
let currentBranch = '';
|
|
@@ -238,10 +309,8 @@ export async function startRepl(opts = {}) {
|
|
|
238
309
|
catch { /* best-effort */ }
|
|
239
310
|
function persistMap(file, m) {
|
|
240
311
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
244
|
-
fs.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
|
|
312
|
+
fsSync.mkdirSync(nodePath.dirname(file), { recursive: true });
|
|
313
|
+
fsSync.writeFileSync(file, JSON.stringify(Object.fromEntries(m), null, 2), 'utf-8');
|
|
245
314
|
}
|
|
246
315
|
catch { /* best-effort */ }
|
|
247
316
|
}
|
|
@@ -766,8 +835,10 @@ export async function startRepl(opts = {}) {
|
|
|
766
835
|
}
|
|
767
836
|
else {
|
|
768
837
|
queuedStatus({ model: next });
|
|
769
|
-
queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (
|
|
838
|
+
queuedAppend({ id: `mdl2-${Date.now()}`, kind: 'text', text: `model switched to ${next} (saved — new terminals inherit it)`, role: 'assistant' });
|
|
770
839
|
model = next;
|
|
840
|
+
modelTouched = true;
|
|
841
|
+
await persistProviderPatch({ model: next });
|
|
771
842
|
}
|
|
772
843
|
return;
|
|
773
844
|
}
|
|
@@ -785,7 +856,9 @@ export async function startRepl(opts = {}) {
|
|
|
785
856
|
}
|
|
786
857
|
currentProvider = next;
|
|
787
858
|
adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
788
|
-
|
|
859
|
+
providerTouched = true;
|
|
860
|
+
await persistProviderPatch({ provider: next });
|
|
861
|
+
queuedAppend({ id: `prov2-${Date.now()}`, kind: 'text', text: `provider switched to ${next} (saved — new terminals inherit it)`, role: 'assistant' });
|
|
789
862
|
}
|
|
790
863
|
return;
|
|
791
864
|
}
|
|
@@ -806,9 +879,15 @@ export async function startRepl(opts = {}) {
|
|
|
806
879
|
return;
|
|
807
880
|
}
|
|
808
881
|
case 'login': {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
882
|
+
// NOTE: readline login cannot run inside the TUI (Ink owns stdin —
|
|
883
|
+
// prompts would garble). Secrets must also never echo into the
|
|
884
|
+
// transcript, so login lives outside: run it once, /reload picks it up.
|
|
885
|
+
queuedAppend({
|
|
886
|
+
id: `login-${Date.now()}`,
|
|
887
|
+
kind: 'text',
|
|
888
|
+
text: 'To sign in, run `klyro login` in any terminal (asks once, saves for all terminals), then /reload here.\n(Interactive prompts cannot run inside the TUI — Ink owns stdin.)',
|
|
889
|
+
role: 'assistant',
|
|
890
|
+
});
|
|
812
891
|
return;
|
|
813
892
|
}
|
|
814
893
|
case 'logout': {
|
|
@@ -1984,11 +2063,37 @@ export async function startRepl(opts = {}) {
|
|
|
1984
2063
|
}
|
|
1985
2064
|
case 'reload': {
|
|
1986
2065
|
try {
|
|
2066
|
+
// Re-resolve the provider so `klyro login` (or config edits) in
|
|
2067
|
+
// another terminal take effect here. Explicit in-session switches
|
|
2068
|
+
// (/model, /provider) are never clobbered.
|
|
2069
|
+
const fresh = await resolveProvider();
|
|
2070
|
+
const notes = [];
|
|
2071
|
+
if (fresh) {
|
|
2072
|
+
if (!modelTouched && fresh.model && fresh.model !== model) {
|
|
2073
|
+
model = fresh.model;
|
|
2074
|
+
queuedStatus({ model });
|
|
2075
|
+
notes.push(`model → ${model}`);
|
|
2076
|
+
}
|
|
2077
|
+
if (!providerTouched) {
|
|
2078
|
+
const { inferProviderFromBaseURL: infer } = await import('../agent/registry.js');
|
|
2079
|
+
const prov = infer(fresh.baseURL);
|
|
2080
|
+
currentProvider = prov;
|
|
2081
|
+
currentBaseUrl = fresh.baseURL;
|
|
2082
|
+
currentApiKey = fresh.apiKey;
|
|
2083
|
+
adapter = buildAdapter(currentProvider, currentBaseUrl, currentApiKey);
|
|
2084
|
+
notes.push(`provider → ${prov} (${fresh.baseURL}, ${fresh.source})`);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1987
2087
|
const ctx = await buildLevel6Context({ cwd });
|
|
1988
2088
|
ctxPrefix = ctx.formatted ? `\n\n<context>\n${ctx.formatted}\n</context>` : '';
|
|
1989
2089
|
const md = await import('../context/klyro-md.js').then((m) => m.loadKlyroMd(cwd)).catch(() => '');
|
|
1990
2090
|
klyroBlock = md ? `\n\n<KLYRO.md>\n${md.slice(0, 4000)}\n</KLYRO.md>` : '';
|
|
1991
|
-
queuedAppend({
|
|
2091
|
+
queuedAppend({
|
|
2092
|
+
id: `reload-${Date.now()}`,
|
|
2093
|
+
kind: 'text',
|
|
2094
|
+
text: notes.length > 0 ? `reloaded: ${notes.join(', ')} + project context + KLYRO.md` : 'reloaded project context + KLYRO.md (provider unchanged)',
|
|
2095
|
+
role: 'assistant',
|
|
2096
|
+
});
|
|
1992
2097
|
}
|
|
1993
2098
|
catch (err) {
|
|
1994
2099
|
queuedAppend({ id: `reload-err-${Date.now()}`, kind: 'error', message: String(err) });
|
|
@@ -2159,6 +2264,7 @@ export async function startRepl(opts = {}) {
|
|
|
2159
2264
|
process.removeListener('SIGTERM', sigintHandler);
|
|
2160
2265
|
}
|
|
2161
2266
|
restoreConsole();
|
|
2267
|
+
removeMouseTap();
|
|
2162
2268
|
leaveAlt();
|
|
2163
2269
|
// §1.2 exit behavior: replay a plain-text transcript into the main
|
|
2164
2270
|
// buffer so the session survives in native scrollback.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup — "set once, works in every terminal".
|
|
3
|
+
*
|
|
4
|
+
* When Klyro starts with no provider anywhere (no env, no saved config, no
|
|
5
|
+
* stored keys, no local server), the TUI asks for provider details ONCE via
|
|
6
|
+
* readline (stdin is free — the Ink App is not mounted yet), persists them
|
|
7
|
+
* to ~/.klyro/settings.json + ~/.klyro/credentials.json (0600), and startup
|
|
8
|
+
* continues. The next terminal never asks again; changes happen explicitly
|
|
9
|
+
* via /provider, /model, `klyro login`, or `klyro config`.
|
|
10
|
+
*
|
|
11
|
+
* The `ask` callback is injected so this is unit-testable without a TTY.
|
|
12
|
+
*/
|
|
13
|
+
export interface SetupAnswers {
|
|
14
|
+
provider: 'openai' | 'anthropic';
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
model: string;
|
|
17
|
+
keySaved: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Run the interactive setup. Returns answers, or null if the user aborted
|
|
21
|
+
* (empty provider choice) — the caller should exit(2) in that case.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runFirstRunSetup(ask: (question: string) => Promise<string>): Promise<SetupAnswers | null>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run setup — "set once, works in every terminal".
|
|
3
|
+
*
|
|
4
|
+
* When Klyro starts with no provider anywhere (no env, no saved config, no
|
|
5
|
+
* stored keys, no local server), the TUI asks for provider details ONCE via
|
|
6
|
+
* readline (stdin is free — the Ink App is not mounted yet), persists them
|
|
7
|
+
* to ~/.klyro/settings.json + ~/.klyro/credentials.json (0600), and startup
|
|
8
|
+
* continues. The next terminal never asks again; changes happen explicitly
|
|
9
|
+
* via /provider, /model, `klyro login`, or `klyro config`.
|
|
10
|
+
*
|
|
11
|
+
* The `ask` callback is injected so this is unit-testable without a TTY.
|
|
12
|
+
*/
|
|
13
|
+
import { LOGIN_DEFAULTS, saveKey } from './auth.js';
|
|
14
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
15
|
+
import { assertSafeBaseURL } from '../chat.js';
|
|
16
|
+
/**
|
|
17
|
+
* Run the interactive setup. Returns answers, or null if the user aborted
|
|
18
|
+
* (empty provider choice) — the caller should exit(2) in that case.
|
|
19
|
+
*/
|
|
20
|
+
export async function runFirstRunSetup(ask) {
|
|
21
|
+
const choiceRaw = await ask('No provider configured (one-time setup — saved for all terminals).\nProvider [1=openai-compatible, 2=anthropic, 3=local Ollama] [1]: ');
|
|
22
|
+
const choice = choiceRaw.trim() || '1';
|
|
23
|
+
if (choice !== '1' && choice !== '2' && choice !== '3')
|
|
24
|
+
return null;
|
|
25
|
+
const name = choice === '2' ? 'anthropic' : choice === '3' ? 'local' : 'openai';
|
|
26
|
+
const defs = LOGIN_DEFAULTS[name] ?? LOGIN_DEFAULTS.openai;
|
|
27
|
+
const key = await ask(name === 'local' ? 'API key (empty = none needed for local): ' : 'API key (paste, stored 0600): ');
|
|
28
|
+
if (!key.trim() && name !== 'local')
|
|
29
|
+
return null;
|
|
30
|
+
const baseRaw = await ask(`Base URL [${defs.baseUrl}]: `);
|
|
31
|
+
const baseUrl = (baseRaw.trim() || defs.baseUrl).trim();
|
|
32
|
+
try {
|
|
33
|
+
assertSafeBaseURL(baseUrl);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const modelRaw = await ask(`Model [${defs.model}]: `);
|
|
39
|
+
const model = (modelRaw.trim() || defs.model).trim();
|
|
40
|
+
const storeProvider = name === 'local' ? 'openai' : name;
|
|
41
|
+
if (key.trim())
|
|
42
|
+
await saveKey(storeProvider, key);
|
|
43
|
+
const cfg = await loadConfig();
|
|
44
|
+
cfg.provider = storeProvider;
|
|
45
|
+
cfg.baseUrl = baseUrl;
|
|
46
|
+
cfg.model = model;
|
|
47
|
+
await saveConfig(cfg);
|
|
48
|
+
return { provider: storeProvider, baseUrl, model, keySaved: key.trim().length > 0 };
|
|
49
|
+
}
|
package/dist/providers.d.ts
CHANGED
|
@@ -15,9 +15,21 @@ export interface ProviderConfig {
|
|
|
15
15
|
baseURL: string;
|
|
16
16
|
apiKey: string;
|
|
17
17
|
model: string;
|
|
18
|
-
source: 'env' | 'local-probe' | 'manual';
|
|
18
|
+
source: 'env' | 'config' | 'local-probe' | 'manual';
|
|
19
19
|
}
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Resolve which provider to use. Does not throw; returns `null` if nothing
|
|
22
|
+
* is reachable.
|
|
23
|
+
*
|
|
24
|
+
* Precedence (later lines are fallbacks, earlier wins):
|
|
25
|
+
* 1. KLYRO_BASE_URL env (+ KLYRO_API_KEY / KLYRO_MODEL)
|
|
26
|
+
* 2. Persisted config (~/.klyro/settings.json: baseUrl, provider, model,
|
|
27
|
+
* apiKey) + stored credentials (~/.klyro/credentials.json) — set once
|
|
28
|
+
* via `klyro login` or first-run setup, applies to ALL terminals.
|
|
29
|
+
* 3. KLYRO_API_KEY env alone (OpenAI default)
|
|
30
|
+
* 4. Stored credential keys alone (provider inferred from which key exists)
|
|
31
|
+
* 5. Local endpoint probe (Ollama / LM Studio / vLLM / llama.cpp)
|
|
32
|
+
*/
|
|
21
33
|
export declare function resolveProvider(): Promise<ProviderConfig | null>;
|
|
22
34
|
/** Pretty-print a hint about how to configure the provider. */
|
|
23
35
|
export declare function providerHelp(p: ProviderConfig | null): string;
|
package/dist/providers.js
CHANGED
|
@@ -18,7 +18,19 @@ const LOCAL_ENDPOINTS = [
|
|
|
18
18
|
{ name: 'vLLM', baseURL: 'http://localhost:8000/v1', defaultModel: 'meta-llama/Llama-3-8B-Instruct' },
|
|
19
19
|
{ name: 'llama.cpp', baseURL: 'http://localhost:8080/v1', defaultModel: 'local-model' },
|
|
20
20
|
];
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Resolve which provider to use. Does not throw; returns `null` if nothing
|
|
23
|
+
* is reachable.
|
|
24
|
+
*
|
|
25
|
+
* Precedence (later lines are fallbacks, earlier wins):
|
|
26
|
+
* 1. KLYRO_BASE_URL env (+ KLYRO_API_KEY / KLYRO_MODEL)
|
|
27
|
+
* 2. Persisted config (~/.klyro/settings.json: baseUrl, provider, model,
|
|
28
|
+
* apiKey) + stored credentials (~/.klyro/credentials.json) — set once
|
|
29
|
+
* via `klyro login` or first-run setup, applies to ALL terminals.
|
|
30
|
+
* 3. KLYRO_API_KEY env alone (OpenAI default)
|
|
31
|
+
* 4. Stored credential keys alone (provider inferred from which key exists)
|
|
32
|
+
* 5. Local endpoint probe (Ollama / LM Studio / vLLM / llama.cpp)
|
|
33
|
+
*/
|
|
22
34
|
export async function resolveProvider() {
|
|
23
35
|
const envBaseURL = process.env.KLYRO_BASE_URL;
|
|
24
36
|
const envKey = process.env.KLYRO_API_KEY;
|
|
@@ -32,6 +44,62 @@ export async function resolveProvider() {
|
|
|
32
44
|
source: 'env',
|
|
33
45
|
};
|
|
34
46
|
}
|
|
47
|
+
// Persisted config — the "set once, works in every terminal" layer.
|
|
48
|
+
try {
|
|
49
|
+
const { loadMergedConfig } = await import('./cli/config.js');
|
|
50
|
+
const { getStoredKey } = await import('./cli/auth.js');
|
|
51
|
+
const cfg = await loadMergedConfig(process.cwd(), {});
|
|
52
|
+
const cfgBase = (cfg.baseUrl ?? cfg.baseURL);
|
|
53
|
+
const cfgProvider = cfg.provider;
|
|
54
|
+
const cfgModel = (cfg.model ?? cfg['model.default']);
|
|
55
|
+
const cfgKey = (cfg.apiKey ?? cfg.api_key);
|
|
56
|
+
if (cfgBase) {
|
|
57
|
+
assertSafeBaseURL(cfgBase);
|
|
58
|
+
const key = envKey ?? cfgKey ?? getStoredKey(cfgProvider ?? 'openai') ?? getStoredKey('openai') ?? getStoredKey('anthropic') ?? '';
|
|
59
|
+
return {
|
|
60
|
+
baseURL: normalizeBaseURL(cfgBase),
|
|
61
|
+
apiKey: key,
|
|
62
|
+
model: envModel ?? cfgModel ?? 'gpt-4o-mini',
|
|
63
|
+
source: 'config',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
if (cfgKey) {
|
|
67
|
+
return {
|
|
68
|
+
baseURL: normalizeBaseURL('https://api.openai.com/v1'),
|
|
69
|
+
apiKey: cfgKey,
|
|
70
|
+
model: envModel ?? cfgModel ?? 'gpt-4o-mini',
|
|
71
|
+
source: 'config',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// Stored keys alone (e.g. `klyro login` with defaults, or key-only setup).
|
|
75
|
+
const anthropicKey = getStoredKey('anthropic');
|
|
76
|
+
const openaiKey = getStoredKey('openai');
|
|
77
|
+
if (cfgProvider === 'anthropic' && anthropicKey) {
|
|
78
|
+
return {
|
|
79
|
+
baseURL: normalizeBaseURL('https://api.anthropic.com/v1'),
|
|
80
|
+
apiKey: anthropicKey,
|
|
81
|
+
model: envModel ?? cfgModel ?? 'claude-3-5-sonnet-20240620',
|
|
82
|
+
source: 'config',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (openaiKey) {
|
|
86
|
+
return {
|
|
87
|
+
baseURL: normalizeBaseURL('https://api.openai.com/v1'),
|
|
88
|
+
apiKey: openaiKey,
|
|
89
|
+
model: envModel ?? cfgModel ?? 'gpt-4o-mini',
|
|
90
|
+
source: 'config',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (anthropicKey) {
|
|
94
|
+
return {
|
|
95
|
+
baseURL: normalizeBaseURL('https://api.anthropic.com/v1'),
|
|
96
|
+
apiKey: anthropicKey,
|
|
97
|
+
model: envModel ?? cfgModel ?? 'claude-3-5-sonnet-20240620',
|
|
98
|
+
source: 'config',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch { /* corrupted config must not break startup; probe below */ }
|
|
35
103
|
if (envKey) {
|
|
36
104
|
return {
|
|
37
105
|
baseURL: normalizeBaseURL('https://api.openai.com/v1'),
|
|
@@ -43,12 +111,25 @@ export async function resolveProvider() {
|
|
|
43
111
|
// Probe local endpoints
|
|
44
112
|
for (const ep of LOCAL_ENDPOINTS) {
|
|
45
113
|
if (await probeLocal(ep.baseURL)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
model
|
|
50
|
-
|
|
51
|
-
|
|
114
|
+
try {
|
|
115
|
+
const { loadMergedConfig } = await import('./cli/config.js');
|
|
116
|
+
const cfg = await loadMergedConfig(process.cwd(), {});
|
|
117
|
+
const cfgModel = (cfg.model ?? cfg['model.default']);
|
|
118
|
+
return {
|
|
119
|
+
baseURL: ep.baseURL,
|
|
120
|
+
apiKey: '',
|
|
121
|
+
model: envModel ?? cfgModel ?? ep.defaultModel,
|
|
122
|
+
source: 'local-probe',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return {
|
|
127
|
+
baseURL: ep.baseURL,
|
|
128
|
+
apiKey: '',
|
|
129
|
+
model: envModel ?? ep.defaultModel,
|
|
130
|
+
source: 'local-probe',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
52
133
|
}
|
|
53
134
|
}
|
|
54
135
|
return null;
|
|
@@ -80,5 +161,8 @@ export function providerHelp(p) {
|
|
|
80
161
|
if (p?.source === 'env') {
|
|
81
162
|
return `(env: ${p.baseURL}, model: ${p.model})`;
|
|
82
163
|
}
|
|
83
|
-
|
|
164
|
+
if (p?.source === 'config') {
|
|
165
|
+
return `(saved: ${p.baseURL}, model: ${p.model} — change via /provider /model or klyro config)`;
|
|
166
|
+
}
|
|
167
|
+
return `(no provider configured — run klyro login once, or set KLYRO_BASE_URL + KLYRO_API_KEY, or run a local server like Ollama)`;
|
|
84
168
|
}
|
package/dist/tui/app.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface AppProps {
|
|
|
22
22
|
updateStatus: (s: Partial<StatusSnapshot>) => void;
|
|
23
23
|
updatePlan: (p: PlanStep[]) => void;
|
|
24
24
|
clearTranscript: () => void;
|
|
25
|
+
scrollLines: (delta: number) => void;
|
|
26
|
+
scrollToBottom: () => void;
|
|
25
27
|
}) => void;
|
|
26
28
|
version?: string;
|
|
27
29
|
isFullscreen?: boolean;
|
package/dist/tui/app.js
CHANGED
|
@@ -201,6 +201,8 @@ export function App(props) {
|
|
|
201
201
|
setHistory((prev) => (prev[prev.length - 1] === v ? prev : [...prev.slice(-99), v]));
|
|
202
202
|
setHistIdx(null);
|
|
203
203
|
}, []);
|
|
204
|
+
// Live scroll control for external drivers (mouse-wheel tap in repl.ts).
|
|
205
|
+
const scrollCmdsRef = useRef({ line: (_d) => { }, bottom: () => { } });
|
|
204
206
|
const width = stdout?.columns ?? 100;
|
|
205
207
|
const height = stdout?.rows ?? 30;
|
|
206
208
|
const isFullscreen = props.isFullscreen ?? false;
|
|
@@ -318,6 +320,19 @@ export function App(props) {
|
|
|
318
320
|
}
|
|
319
321
|
}
|
|
320
322
|
const visibleGrouped = isFullscreen && !tiny ? grouped.slice(gi0, gi1 + 1) : grouped;
|
|
323
|
+
// Publish live scroll control for the mouse-wheel tap (stable callbacks, latest ctx).
|
|
324
|
+
scrollCmdsRef.current = {
|
|
325
|
+
line: (d) => {
|
|
326
|
+
const n = Math.abs(Math.round(d));
|
|
327
|
+
for (let i = 0; i < n; i++) {
|
|
328
|
+
if (d < 0)
|
|
329
|
+
commands.lineUp();
|
|
330
|
+
else
|
|
331
|
+
commands.lineDown();
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
bottom: () => commands.jumpBottom(),
|
|
335
|
+
};
|
|
321
336
|
useEffect(() => bridge.subscribe((p) => setAwaitingApproval(p !== null)), [bridge]);
|
|
322
337
|
useEffect(() => {
|
|
323
338
|
if (queuedInputs.length > 0 && status.status !== 'running' && !awaitingApproval) {
|
|
@@ -354,9 +369,13 @@ export function App(props) {
|
|
|
354
369
|
const updateStatus = useCallback((s) => setStatus((p) => ({ ...p, ...s })), []);
|
|
355
370
|
const updatePlan = useCallback((p) => setPlan(p), []);
|
|
356
371
|
const clearTranscript = useCallback(() => { streamingIdRef.current = null; setTranscript([]); setPlan([]); }, []);
|
|
372
|
+
// Scroll control for external drivers (mouse-wheel tap in repl.ts, §8.4).
|
|
373
|
+
// Stored in refs so the callbacks stay stable while acting on latest state.
|
|
374
|
+
const scrollLines = useCallback((delta) => { scrollCmdsRef.current.line(delta); }, []);
|
|
375
|
+
const scrollToBottom = useCallback(() => { scrollCmdsRef.current.bottom(); }, []);
|
|
357
376
|
const onMountedRef = useRef(props.onMounted);
|
|
358
377
|
useEffect(() => { onMountedRef.current = props.onMounted; }, [props.onMounted]);
|
|
359
|
-
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript]);
|
|
378
|
+
useEffect(() => { onMountedRef.current?.({ append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom }); globalThis.__klyroAppAppend = append; globalThis.__klyroAppendDelta = appendDelta; globalThis.__klyroAppStatus = updateStatus; globalThis.__klyroAppPlan = updatePlan; return () => { delete globalThis.__klyroAppAppend; delete globalThis.__klyroAppendDelta; delete globalThis.__klyroAppStatus; delete globalThis.__klyroAppPlan; }; }, [append, appendDelta, updateStatus, updatePlan, clearTranscript, scrollLines, scrollToBottom]);
|
|
360
379
|
const toggleGroup = (id) => setExpandedGroups((prev) => { const n = new Set(prev); if (n.has(id))
|
|
361
380
|
n.delete(id);
|
|
362
381
|
else
|
|
@@ -390,11 +409,11 @@ export function App(props) {
|
|
|
390
409
|
commands.jumpBottom();
|
|
391
410
|
return;
|
|
392
411
|
} // Ctrl+G → bottom (§8.4)
|
|
393
|
-
if (key.pageUp || (key.ctrl && inputStr === 'u')) {
|
|
412
|
+
if (key.pageUp || (key.ctrl && inputStr === 'u') || (key.ctrl && inputStr === 'b')) {
|
|
394
413
|
commands.pageUp();
|
|
395
414
|
return;
|
|
396
415
|
}
|
|
397
|
-
if (key.pageDown || (key.ctrl && inputStr === 'd')) {
|
|
416
|
+
if (key.pageDown || (key.ctrl && inputStr === 'd') || (key.ctrl && inputStr === 'f')) {
|
|
398
417
|
commands.pageDown();
|
|
399
418
|
return;
|
|
400
419
|
}
|
package/dist/tui/app.test.js
CHANGED
|
@@ -249,6 +249,25 @@ describe('App', () => {
|
|
|
249
249
|
await new Promise((r) => setTimeout(r, 100));
|
|
250
250
|
expect(lastFrame() ?? '').toMatch(/LATE-1-tag/);
|
|
251
251
|
});
|
|
252
|
+
it('onMounted scrollLines/scrollToBottom drive the viewport (wheel path)', async () => {
|
|
253
|
+
let captured = null;
|
|
254
|
+
const { lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25), onMounted: (h) => {
|
|
255
|
+
captured = { scrollLines: h.scrollLines, scrollToBottom: h.scrollToBottom };
|
|
256
|
+
} }));
|
|
257
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
258
|
+
expect(captured).not.toBeNull();
|
|
259
|
+
// Wheel up ×12 (3 lines each = 36 > maxTop 32) → pinned at top.
|
|
260
|
+
for (let i = 0; i < 12; i++)
|
|
261
|
+
captured.scrollLines(-3);
|
|
262
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
263
|
+
const top = lastFrame() ?? '';
|
|
264
|
+
expect(top).toMatch(/MSG-00-tag/);
|
|
265
|
+
expect(top).not.toMatch(/MSG-24-tag/);
|
|
266
|
+
// scrollToBottom → tail visible again.
|
|
267
|
+
captured.scrollToBottom();
|
|
268
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
269
|
+
expect(lastFrame() ?? '').toMatch(/MSG-24-tag/);
|
|
270
|
+
});
|
|
252
271
|
it('Shift+Up / Shift+Down scroll by one line', async () => {
|
|
253
272
|
const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
|
|
254
273
|
await new Promise((r) => setTimeout(r, 50));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §8.5 (S8) — mouse wheel capture for OpenCode-style scrolling.
|
|
3
|
+
*
|
|
4
|
+
* Ink owns stdin via useInput and cannot see mouse events, so the REPL wraps
|
|
5
|
+
* `process.stdin.emit` with this stateful splitter: SGR/X10 mouse sequences
|
|
6
|
+
* are swallowed (wheel → scroll deltas, clicks/motion → dropped), everything
|
|
7
|
+
* else passes through to Ink untouched.
|
|
8
|
+
*
|
|
9
|
+
* Only the wheel is acted on. Clicks are swallowed (not forwarded) because a
|
|
10
|
+
* mouse-reporting terminal would otherwise deliver them to readline as typed
|
|
11
|
+
* garbage; Shift+drag still selects natively in most terminals.
|
|
12
|
+
*
|
|
13
|
+
* Sequences handled:
|
|
14
|
+
* SGR: `\x1b[<Cb;x;yM` / `...m` (1006, requested via `CSI ? 1006 h`)
|
|
15
|
+
* X10: `\x1b[M Cb Cx Cy` (fallback for terminals ignoring 1006)
|
|
16
|
+
* Wheel bit is 64 in both; direction bit is 1 (down) — modifiers OR into Cb.
|
|
17
|
+
*/
|
|
18
|
+
export declare const WHEEL_LINES = 3;
|
|
19
|
+
export interface MouseSplit {
|
|
20
|
+
/** bytes Ink is still allowed to see */
|
|
21
|
+
kept: Buffer;
|
|
22
|
+
/** wheel deltas: negative = up (older), positive = down (newer) */
|
|
23
|
+
wheels: number[];
|
|
24
|
+
}
|
|
25
|
+
export declare class MouseFilter {
|
|
26
|
+
private pending;
|
|
27
|
+
/**
|
|
28
|
+
* Split one stdin chunk. Holds an unambiguous trailing partial mouse
|
|
29
|
+
* sequence for the next chunk; a lone trailing ESC passes through
|
|
30
|
+
* immediately so the Esc key (queued-drop) never lags.
|
|
31
|
+
*/
|
|
32
|
+
push(chunk: Buffer): MouseSplit;
|
|
33
|
+
reset(): void;
|
|
34
|
+
}
|
|
35
|
+
export declare const MOUSE_ENABLE = "\u001B[?1000h\u001B[?1006h";
|
|
36
|
+
export declare const MOUSE_DISABLE = "\u001B[?1000l\u001B[?1006l";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scroll.md §8.5 (S8) — mouse wheel capture for OpenCode-style scrolling.
|
|
3
|
+
*
|
|
4
|
+
* Ink owns stdin via useInput and cannot see mouse events, so the REPL wraps
|
|
5
|
+
* `process.stdin.emit` with this stateful splitter: SGR/X10 mouse sequences
|
|
6
|
+
* are swallowed (wheel → scroll deltas, clicks/motion → dropped), everything
|
|
7
|
+
* else passes through to Ink untouched.
|
|
8
|
+
*
|
|
9
|
+
* Only the wheel is acted on. Clicks are swallowed (not forwarded) because a
|
|
10
|
+
* mouse-reporting terminal would otherwise deliver them to readline as typed
|
|
11
|
+
* garbage; Shift+drag still selects natively in most terminals.
|
|
12
|
+
*
|
|
13
|
+
* Sequences handled:
|
|
14
|
+
* SGR: `\x1b[<Cb;x;yM` / `...m` (1006, requested via `CSI ? 1006 h`)
|
|
15
|
+
* X10: `\x1b[M Cb Cx Cy` (fallback for terminals ignoring 1006)
|
|
16
|
+
* Wheel bit is 64 in both; direction bit is 1 (down) — modifiers OR into Cb.
|
|
17
|
+
*/
|
|
18
|
+
export const WHEEL_LINES = 3; // scroll.md §8.4: wheel = ±3 lines
|
|
19
|
+
function isDigit(b) {
|
|
20
|
+
return b >= 0x30 && b <= 0x39;
|
|
21
|
+
}
|
|
22
|
+
export class MouseFilter {
|
|
23
|
+
pending = Buffer.alloc(0);
|
|
24
|
+
/**
|
|
25
|
+
* Split one stdin chunk. Holds an unambiguous trailing partial mouse
|
|
26
|
+
* sequence for the next chunk; a lone trailing ESC passes through
|
|
27
|
+
* immediately so the Esc key (queued-drop) never lags.
|
|
28
|
+
*/
|
|
29
|
+
push(chunk) {
|
|
30
|
+
const buf = Buffer.concat([this.pending, chunk]);
|
|
31
|
+
this.pending = Buffer.alloc(0);
|
|
32
|
+
const kept = [];
|
|
33
|
+
const wheels = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
const n = buf.length;
|
|
36
|
+
while (i < n) {
|
|
37
|
+
// SGR mouse: ESC [ < Cb ; x ; y (M|m)
|
|
38
|
+
if (buf[i] === 0x1b && i + 2 < n && buf[i + 1] === 0x5b && buf[i + 2] === 0x3c) {
|
|
39
|
+
let j = i + 3;
|
|
40
|
+
while (j < n && (isDigit(buf[j]) || buf[j] === 0x3b))
|
|
41
|
+
j++;
|
|
42
|
+
if (j >= n) {
|
|
43
|
+
// split across chunks — hold for more data
|
|
44
|
+
this.pending = buf.subarray(i);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
const term = buf[j];
|
|
48
|
+
if (term === 0x4d || term === 0x6d) {
|
|
49
|
+
const cb = parseInt(buf.subarray(i + 3, j).toString().split(';')[0] ?? 'NaN', 10);
|
|
50
|
+
if (!Number.isNaN(cb) && (cb & 64) !== 0) {
|
|
51
|
+
wheels.push((cb & 1) === 0 ? -WHEEL_LINES : WHEEL_LINES);
|
|
52
|
+
}
|
|
53
|
+
i = j + 1; // swallow (wheel or click/motion)
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// ESC [ < not followed by digits→M/m: not a mouse seq, pass ESC through
|
|
57
|
+
kept.push(buf.subarray(i, i + 1));
|
|
58
|
+
i++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
// X10 mouse: ESC [ M Cb Cx Cy
|
|
62
|
+
if (buf[i] === 0x1b && i + 2 < n && buf[i + 1] === 0x5b && buf[i + 2] === 0x4d) {
|
|
63
|
+
if (i + 5 >= n) {
|
|
64
|
+
this.pending = buf.subarray(i); // split across chunks
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
const cb = buf[i + 3] - 32;
|
|
68
|
+
if ((cb & 64) !== 0) {
|
|
69
|
+
wheels.push((cb & 1) === 0 ? -WHEEL_LINES : WHEEL_LINES);
|
|
70
|
+
}
|
|
71
|
+
i += 6; // swallow
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
// Trailing partial that could ONLY be a split SGR/X10 start: hold it.
|
|
75
|
+
// A lone trailing ESC passes through (Esc key must not lag).
|
|
76
|
+
const tail = n - i;
|
|
77
|
+
if (tail <= 5) {
|
|
78
|
+
const rest = buf.subarray(i).toString('latin1');
|
|
79
|
+
if (/^\x1b\[<$/.test(rest) || /^\x1b\[<[\d;]+$/.test(rest) || /^\x1b\[M.{0,2}$/.test(rest)) {
|
|
80
|
+
this.pending = buf.subarray(i);
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
kept.push(buf.subarray(i, i + 1));
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
return { kept: Buffer.concat(kept), wheels };
|
|
88
|
+
}
|
|
89
|
+
reset() {
|
|
90
|
+
this.pending = Buffer.alloc(0);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export const MOUSE_ENABLE = '\x1b[?1000h\x1b[?1006h'; // button events + SGR coords
|
|
94
|
+
export const MOUSE_DISABLE = '\x1b[?1000l\x1b[?1006l';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* scroll.md diagnostic: realistic session flow — seed history, stream a long
|
|
4
|
+
* answer in chunks (like provider deltas), scroll mid-stream, stream more.
|
|
5
|
+
* Asserts the chat-flow invariants the user actually sees:
|
|
6
|
+
* - follow-tail: latest streamed text visible while at bottom
|
|
7
|
+
* - freeze: pinned viewport doesn't move while streaming
|
|
8
|
+
* - badge counts new lines
|
|
9
|
+
* - frame never exceeds terminal rows (I1)
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { render } from 'ink-testing-library';
|
|
13
|
+
import { App } from './app.js';
|
|
14
|
+
const PROPS = {
|
|
15
|
+
initialModel: 'm',
|
|
16
|
+
maxSteps: 10,
|
|
17
|
+
cwd: '/test',
|
|
18
|
+
onPrompt: async () => { },
|
|
19
|
+
onSlash: async () => { },
|
|
20
|
+
};
|
|
21
|
+
const g = globalThis;
|
|
22
|
+
const tick = (ms = 30) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
const rowsOf = (frame) => frame.split('\n').length;
|
|
24
|
+
function seed(n) {
|
|
25
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
26
|
+
id: `seed-${i}`,
|
|
27
|
+
kind: 'text',
|
|
28
|
+
text: `MSG-${i.toString().padStart(2, '0')}-tag`,
|
|
29
|
+
role: 'user',
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
describe('scroll flow diagnostics', () => {
|
|
33
|
+
it('reports terminal geometry (debug aid)', async () => {
|
|
34
|
+
const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true }));
|
|
35
|
+
await tick(50);
|
|
36
|
+
const frame = lastFrame() ?? '';
|
|
37
|
+
// eslint-disable-next-line no-console
|
|
38
|
+
console.log(`[diag] frame rows=${rowsOf(frame)} cols~${(frame.split('\n')[0] ?? '').length}`);
|
|
39
|
+
expect(rowsOf(frame)).toBeLessThanOrEqual(32);
|
|
40
|
+
});
|
|
41
|
+
it('follow-tail: streamed long answer stays visible, frame stays bounded', async () => {
|
|
42
|
+
const { lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: seed(5) }));
|
|
43
|
+
await tick(50);
|
|
44
|
+
g.__klyroAppStatus({ status: 'running' });
|
|
45
|
+
const chunk = 'STREAMCHUNK lorem ipsum dolor sit amet. ';
|
|
46
|
+
for (let i = 0; i < 12; i++) {
|
|
47
|
+
g.__klyroAppendDelta(`${chunk}#${i} `);
|
|
48
|
+
await tick(40);
|
|
49
|
+
const frame = lastFrame() ?? '';
|
|
50
|
+
expect(rowsOf(frame)).toBeLessThanOrEqual(32);
|
|
51
|
+
// latest streamed chunk must be visible (follow-tail)
|
|
52
|
+
expect(frame).toContain(`#${i}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
it('freeze: pinned top survives streaming, badge counts, End restores', async () => {
|
|
56
|
+
const { stdin, lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: seed(40) }));
|
|
57
|
+
await tick(100);
|
|
58
|
+
stdin.write('\x1b[H'); // Home → top
|
|
59
|
+
await tick(50);
|
|
60
|
+
const top = lastFrame() ?? '';
|
|
61
|
+
expect(top).toContain('MSG-00-tag');
|
|
62
|
+
g.__klyroAppStatus({ status: 'running' });
|
|
63
|
+
for (let i = 0; i < 5; i++) {
|
|
64
|
+
g.__klyroAppendDelta(`late chunk number ${i} with filler words here. `);
|
|
65
|
+
await tick(40);
|
|
66
|
+
}
|
|
67
|
+
const frozen = lastFrame() ?? '';
|
|
68
|
+
expect(frozen).toContain('MSG-00-tag'); // viewport did not yank down
|
|
69
|
+
expect(frozen).toMatch(/↓ \d+ new/); // badge visible
|
|
70
|
+
expect(rowsOf(frozen)).toBeLessThanOrEqual(32);
|
|
71
|
+
stdin.write('\x1b[F'); // End → follow
|
|
72
|
+
await tick(50);
|
|
73
|
+
expect(lastFrame() ?? '').toContain('number 4');
|
|
74
|
+
});
|
|
75
|
+
it('wrapped long item: pin mid-item, stream, same first line stays', async () => {
|
|
76
|
+
const long = Array.from({ length: 10 }, (_, i) => `WRAPLINE-${i} ` + 'x'.repeat(180)).join('\n');
|
|
77
|
+
const items = [
|
|
78
|
+
{ id: 'w1', kind: 'text', text: long, role: 'assistant' },
|
|
79
|
+
...seed(30),
|
|
80
|
+
];
|
|
81
|
+
const { stdin, lastFrame } = render(_jsx(App, { ...PROPS, isFullscreen: true, initialTranscript: items }));
|
|
82
|
+
await tick(100);
|
|
83
|
+
stdin.write('\x1b[H');
|
|
84
|
+
await tick(50);
|
|
85
|
+
const before = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
|
|
86
|
+
g.__klyroAppStatus({ status: 'running' });
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
g.__klyroAppendDelta(`more streamed text ${i} ` + 'y'.repeat(120));
|
|
89
|
+
await tick(40);
|
|
90
|
+
}
|
|
91
|
+
const after = (lastFrame() ?? '').split('\n').slice(0, 3).join('\n');
|
|
92
|
+
expect(after).toBe(before); // anchor stability at line granularity
|
|
93
|
+
});
|
|
94
|
+
});
|
package/dist/tui/scroll-model.js
CHANGED
|
@@ -22,10 +22,13 @@ export function maxTopFor(ctx) {
|
|
|
22
22
|
function stickBottom() {
|
|
23
23
|
return { anchor: { mode: 'bottom' }, userScrolled: false, newSinceUnstick: 0 };
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
// FOLLOW_EPSILON is directional: scrolling DOWN into the last line re-sticks
|
|
26
|
+
// to bottom, but scrolling UP must always escape — otherwise single-line /
|
|
27
|
+
// wheel scrolling from the bottom could never leave it (dead scroll trap).
|
|
28
|
+
function pinAt(s, ctx, row, from) {
|
|
26
29
|
const maxTop = maxTopFor(ctx);
|
|
27
30
|
const top = clampN(row, 0, maxTop);
|
|
28
|
-
if (top >= maxTop - FOLLOW_EPSILON)
|
|
31
|
+
if (top >= maxTop - FOLLOW_EPSILON && top >= from)
|
|
29
32
|
return stickBottom();
|
|
30
33
|
if (ctx.count === 0)
|
|
31
34
|
return stickBottom();
|
|
@@ -42,13 +45,13 @@ export function scrollReducer(s, a, ctx) {
|
|
|
42
45
|
const cur = resolveTopRow(s, ctx).topRow;
|
|
43
46
|
switch (a.type) {
|
|
44
47
|
case 'BY_LINES':
|
|
45
|
-
return pinAt(s, ctx, cur + a.delta);
|
|
48
|
+
return pinAt(s, ctx, cur + a.delta, cur);
|
|
46
49
|
case 'BY_PAGE':
|
|
47
|
-
return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1)); // 1-line overlap
|
|
50
|
+
return pinAt(s, ctx, cur + a.dir * (ctx.viewportH - 1), cur); // 1-line overlap
|
|
48
51
|
case 'BY_HALF_PAGE':
|
|
49
|
-
return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2));
|
|
52
|
+
return pinAt(s, ctx, cur + a.dir * Math.floor(ctx.viewportH / 2), cur);
|
|
50
53
|
case 'TO_TOP':
|
|
51
|
-
return pinAt(s, ctx, 0);
|
|
54
|
+
return pinAt(s, ctx, 0, cur);
|
|
52
55
|
case 'TO_BOTTOM':
|
|
53
56
|
return stickBottom();
|
|
54
57
|
case 'CONTENT_GREW':
|
package/package.json
CHANGED