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.
@@ -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 baseURL = opts.baseURL ?? process.env.KLYRO_BASE_URL;
68
- const apiKey = opts.apiKey ?? process.env.KLYRO_API_KEY;
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;
@@ -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 provider = (await rl.question('Provider (anthropic/openai) [openai]: ')) || 'openai';
18
- const key = await rl.question('API key (input hidden, paste): ');
19
- if (!key.trim()) {
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 creds = {};
24
- try {
25
- const raw = await fs.readFile(credPath(), 'utf-8');
26
- Object.assign(creds, JSON.parse(raw));
27
- }
28
- catch { /* ignore */ }
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
- catch { /* ignore on Windows */ }
36
- process.stdout.write(`Saved ${provider} key to ${credPath()} (0600)\n`);
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 = require('node:fs').readFileSync(credPath(), 'utf-8');
111
+ const raw = fsSync.readFileSync(credPath(), 'utf-8');
67
112
  const creds = JSON.parse(raw);
68
- return creds[provider];
113
+ const v = creds[provider];
114
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
69
115
  }
70
116
  catch {
71
117
  return undefined;
@@ -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>;
@@ -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
- const resolved = await resolveProvider();
33
+ let resolved = await resolveProvider();
31
34
  if (!resolved) {
32
- process.stderr.write('klyro: no provider available.\n');
33
- process.stderr.write(` ${providerHelp(null)}\n`);
34
- process.stderr.write(' Set KLYRO_BASE_URL and KLYRO_API_KEY, or run a local server (Ollama, LM Studio, vLLM).\n');
35
- process.stderr.write(' Examples:\n');
36
- process.stderr.write(' set KLYRO_BASE_URL=https://api.openai.com/v1\n');
37
- process.stderr.write(' set KLYRO_API_KEY=sk-...\n');
38
- process.stderr.write(' ollama serve # then KLYRO_BASE_URL=http://localhost:11434/v1 KLYRO_MODEL=llama3.2\n');
39
- return 2;
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 = path.join(home, '.klyro');
185
- fs.mkdirSync(dir, { recursive: true });
186
- fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
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
- const fs = require('node:fs');
242
- const path = require('node:path');
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} (takes effect on next prompt)`, role: 'assistant' });
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
- queuedAppend({ id: `prov2-${Date.now()}`, kind: 'text', text: `provider switched to ${next} (takes effect on next prompt)`, role: 'assistant' });
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
- const { runLogin } = await import('./auth.js');
810
- const code = await runLogin();
811
- queuedAppend({ id: `login-${Date.now()}`, kind: 'text', text: code === 0 ? 'login saved (0600)' : 'login failed', role: 'assistant' });
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({ id: `reload-${Date.now()}`, kind: 'text', text: 'reloaded project context + KLYRO.md', role: 'assistant' });
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
+ }
@@ -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
- /** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
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
- /** Resolve which provider to use. Does not throw; returns `null` if nothing is reachable. */
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
- return {
47
- baseURL: ep.baseURL,
48
- apiKey: '',
49
- model: envModel ?? ep.defaultModel,
50
- source: 'local-probe',
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
- return `(no provider configured — set KLYRO_BASE_URL + KLYRO_API_KEY, or run a local server like Ollama)`;
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
  }
@@ -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
+ });
@@ -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
- function pinAt(s, ctx, row) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "description": "Klyro \u2014 autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",