klyro 0.1.45 → 0.1.47

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/cli/repl.js CHANGED
@@ -19,6 +19,7 @@ import { TuiApprovalBridge } from '../tui/approval.js';
19
19
  import { parseUnifiedDiff } from '../tui/diff-parser.js';
20
20
  import { parse } from './slash/parser.js';
21
21
  import { resolveProvider, providerHelp } from '../providers.js';
22
+ import { MouseFilter, MOUSE_ENABLE, MOUSE_DISABLE } from '../tui/mouse.js';
22
23
  import { inferProviderFromBaseURL } from '../agent/registry.js';
23
24
  import { getDefaultSessionStore } from '../persistence/session.js';
24
25
  import { buildSystemPrompt, parseImageInput } from '../context/system-prompt.js';
@@ -85,7 +86,25 @@ export async function startRepl(opts = {}) {
85
86
  const pendingQueue = [];
86
87
  let isMounted = false;
87
88
  let directHooks;
89
+ // Plain-text mirror for exit replay (scroll.md §1.2: session survives in
90
+ // native scrollback after the alt screen is torn down). Cap 300 lines.
91
+ const exitMirror = [];
92
+ function mirrorLine(item) {
93
+ let line = null;
94
+ if (item.kind === 'text')
95
+ line = `${item.role === 'user' ? '> ' : ''}${item.text}`;
96
+ else if (item.kind === 'error')
97
+ line = `[error] ${item.message}`;
98
+ else if (item.kind === 'file_changed')
99
+ line = `[${item.op}] ${item.path}`;
100
+ if (line === null)
101
+ return;
102
+ exitMirror.push(line.slice(0, 2000));
103
+ if (exitMirror.length > 300)
104
+ exitMirror.splice(0, exitMirror.length - 300);
105
+ }
88
106
  function queuedAppend(item) {
107
+ mirrorLine(item);
89
108
  if (isMounted && directHooks)
90
109
  directHooks.append(item);
91
110
  else
@@ -121,6 +140,7 @@ export async function startRepl(opts = {}) {
121
140
  try {
122
141
  process.stdout.write('\x1b[?1049h\x1b[?25l'); // alt screen + hide cursor
123
142
  process.stdout.write('\x1b[H\x1b[2J'); // home + clear
143
+ process.stdout.write(MOUSE_ENABLE); // wheel events (SGR), see tui/mouse.ts
124
144
  }
125
145
  catch { /* ignore */ }
126
146
  };
@@ -128,10 +148,45 @@ export async function startRepl(opts = {}) {
128
148
  if (!isAltScreen)
129
149
  return;
130
150
  try {
151
+ process.stdout.write(MOUSE_DISABLE);
131
152
  process.stdout.write('\x1b[?25h\x1b[?1049l'); // show cursor + leave alt
132
153
  }
133
154
  catch { /* ignore */ }
134
155
  };
156
+ // OpenCode-style wheel scrolling (§8.5, S8): Ink owns stdin and cannot see
157
+ // mouse events, so tap stdin.emit — wheel deltas drive the App's scroll
158
+ // hooks, everything else passes through to Ink untouched.
159
+ const mouseFilter = new MouseFilter();
160
+ const origStdinEmit = process.stdin.emit.bind(process.stdin);
161
+ let mouseTapInstalled = false;
162
+ function installMouseTap() {
163
+ if (!isAltScreen || mouseTapInstalled)
164
+ return;
165
+ mouseTapInstalled = true;
166
+ process.stdin.emit = (...a) => {
167
+ if (a[0] === 'data' && Buffer.isBuffer(a[1])) {
168
+ const split = mouseFilter.push(a[1]);
169
+ for (const w of split.wheels) {
170
+ try {
171
+ if (isMounted && directHooks)
172
+ directHooks.scrollLines(w);
173
+ }
174
+ catch { /* ignore */ }
175
+ }
176
+ if (split.kept.length === 0)
177
+ return false;
178
+ a[1] = split.kept;
179
+ }
180
+ return origStdinEmit(...a);
181
+ };
182
+ }
183
+ function removeMouseTap() {
184
+ if (!mouseTapInstalled)
185
+ return;
186
+ mouseTapInstalled = false;
187
+ mouseFilter.reset();
188
+ process.stdin.emit = origStdinEmit;
189
+ }
135
190
  // Declare app before handler to avoid TDZ; handler added after render
136
191
  let app;
137
192
  let sigintHandler;
@@ -140,6 +195,50 @@ export async function startRepl(opts = {}) {
140
195
  let tuiSessionId;
141
196
  if (isAltScreen)
142
197
  enterAlt();
198
+ // I7 (scroll.md §8.6, S8): while the TUI owns stdout, route console.*
199
+ // to a ring buffer + ~/.klyro/debug.log so stray tool/provider logs
200
+ // can't corrupt the frame. Restored on exit.
201
+ const consoleRing = [];
202
+ const origConsoleFns = {
203
+ log: console.log,
204
+ info: console.info,
205
+ warn: console.warn,
206
+ error: console.error,
207
+ debug: console.debug,
208
+ };
209
+ function patchConsole() {
210
+ if (!isAltScreen)
211
+ return;
212
+ const sink = (...args) => {
213
+ const line = `[${new Date().toISOString()}] ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`;
214
+ consoleRing.push(line);
215
+ if (consoleRing.length > 200)
216
+ consoleRing.splice(0, consoleRing.length - 200);
217
+ try {
218
+ const fs = require('node:fs');
219
+ const path = require('node:path');
220
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? cwd;
221
+ const dir = path.join(home, '.klyro');
222
+ fs.mkdirSync(dir, { recursive: true });
223
+ fs.appendFileSync(path.join(dir, 'debug.log'), line + '\n');
224
+ }
225
+ catch { /* ignore */ }
226
+ };
227
+ console.log = sink;
228
+ console.info = sink;
229
+ console.warn = sink;
230
+ console.error = sink;
231
+ console.debug = sink;
232
+ }
233
+ function restoreConsole() {
234
+ console.log = origConsoleFns.log;
235
+ console.info = origConsoleFns.info;
236
+ console.warn = origConsoleFns.warn;
237
+ console.error = origConsoleFns.error;
238
+ console.debug = origConsoleFns.debug;
239
+ }
240
+ patchConsole();
241
+ installMouseTap();
143
242
  const EFFORT_STEPS = { low: 10, medium: 30, high: 50, max: 100 };
144
243
  // P1 session/permission state (commands.md Priority 1)
145
244
  let sessionLabel = '';
@@ -278,6 +377,7 @@ export async function startRepl(opts = {}) {
278
377
  leaveAlt();
279
378
  };
280
379
  process.once('SIGINT', sigintHandler);
380
+ process.once('SIGTERM', sigintHandler);
281
381
  async function runWithBridge(text) {
282
382
  if (!model) {
283
383
  queuedAppend({ id: `err-${Date.now()}`, kind: 'error', message: 'no model configured' });
@@ -2092,9 +2192,23 @@ export async function startRepl(opts = {}) {
2092
2192
  // ac.aborted indicates SIGINT; return 130 (128+SIGINT) like shells do.
2093
2193
  return new Promise((resolve) => {
2094
2194
  const onExit = () => {
2095
- if (sigintHandler)
2195
+ if (sigintHandler) {
2096
2196
  process.removeListener('SIGINT', sigintHandler);
2197
+ process.removeListener('SIGTERM', sigintHandler);
2198
+ }
2199
+ restoreConsole();
2200
+ removeMouseTap();
2097
2201
  leaveAlt();
2202
+ // §1.2 exit behavior: replay a plain-text transcript into the main
2203
+ // buffer so the session survives in native scrollback.
2204
+ if (exitMirror.length > 0) {
2205
+ try {
2206
+ process.stdout.write('\n--- klyro session transcript ---\n');
2207
+ for (const line of exitMirror.slice(-100))
2208
+ process.stdout.write(line + '\n');
2209
+ }
2210
+ catch { /* ignore */ }
2211
+ }
2098
2212
  resolve(ac.signal.aborted ? 130 : 0);
2099
2213
  };
2100
2214
  if (!app) {
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;