jaitus 0.3.0
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/LICENSE +32 -0
- package/README.md +248 -0
- package/bin/jaitus.js +60 -0
- package/package.json +46 -0
- package/src/agent/loop.js +548 -0
- package/src/agent/permissions.js +218 -0
- package/src/agent/rules.js +190 -0
- package/src/agent/tools.js +923 -0
- package/src/agent/websearch.js +522 -0
- package/src/api.js +72 -0
- package/src/background.js +85 -0
- package/src/brand.js +232 -0
- package/src/commands/code-review.js +8 -0
- package/src/commands/commit.js +73 -0
- package/src/commands/completion.js +68 -0
- package/src/commands/doctor.js +185 -0
- package/src/commands/explain.js +33 -0
- package/src/commands/init.js +62 -0
- package/src/commands/login.js +201 -0
- package/src/commands/logout.js +25 -0
- package/src/commands/research.js +60 -0
- package/src/commands/review.js +80 -0
- package/src/commands/security-review.js +8 -0
- package/src/commands/simplify.js +8 -0
- package/src/commands/test.js +68 -0
- package/src/commands/update.js +97 -0
- package/src/config.js +95 -0
- package/src/diff.js +129 -0
- package/src/errors.js +101 -0
- package/src/hooks.js +55 -0
- package/src/index.js +2503 -0
- package/src/ledger.js +73 -0
- package/src/logo-data.js +33 -0
- package/src/mcp.js +160 -0
- package/src/open.js +50 -0
- package/src/project.js +0 -0
- package/src/rewind.js +130 -0
- package/src/session.js +207 -0
- package/src/sessions.js +179 -0
- package/src/sixel.js +131 -0
- package/src/skills.js +62 -0
- package/src/term-sim.js +103 -0
- package/src/terminal.js +90 -0
- package/src/tui/ansi.js +287 -0
- package/src/tui/entries.js +366 -0
- package/src/tui/highlight.js +211 -0
- package/src/tui/logo.js +93 -0
- package/src/tui/markdown.js +280 -0
- package/src/tui/mouse.js +125 -0
- package/src/tui/theme.js +201 -0
- package/src/tui/widgets.js +226 -0
- package/src/tui/wordmark.js +135 -0
- package/src/tui-ink.js +1927 -0
- package/src/tui.js +2555 -0
- package/src/ui.js +562 -0
package/src/tui.js
ADDED
|
@@ -0,0 +1,2555 @@
|
|
|
1
|
+
// ─── Fullscreen Jaitus terminal application ────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Renderer invariants (the last renderer violated all four and the cursor desynced):
|
|
4
|
+
//
|
|
5
|
+
// 1. NOTHING is ever written at or beyond the terminal's last column. Autowrap is
|
|
6
|
+
// even disabled (DECAWM off) for the session, so a too-wide row would truncate,
|
|
7
|
+
// never wrap — but every row is hard-capped below the width anyway.
|
|
8
|
+
// 2. No trailing newline is EVER emitted. A `\n` on the bottom screen row scrolls
|
|
9
|
+
// the whole terminal one line — that was the "everything drifts up" bug. Frames
|
|
10
|
+
// are painted with absolute cursor addressing (`\x1b[r;1H`) only.
|
|
11
|
+
// 3. ONE layout function produces the full frame AND the cursor position. The
|
|
12
|
+
// cursor is placed by row index inside the same array that was drawn, so the
|
|
13
|
+
// cursor cannot land somewhere the layout did not put content.
|
|
14
|
+
// 4. The frame is always exactly `rows` tall and repainted from home; a resize
|
|
15
|
+
// simply repaints at the new size.
|
|
16
|
+
//
|
|
17
|
+
// Everything above the render layer (state machine, key handling, transcript,
|
|
18
|
+
// search, dialogs) is unchanged from the version the tests already pin.
|
|
19
|
+
|
|
20
|
+
import readline from 'node:readline';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import { PassThrough } from 'node:stream';
|
|
25
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
26
|
+
import { c, columns, visibleWidth, colorLevel, themeMap, glyph } from './ui.js';
|
|
27
|
+
import { loadSettings } from './config.js';
|
|
28
|
+
import { startupTip, badge } from './brand.js';
|
|
29
|
+
|
|
30
|
+
const rawWrite = (text = '') => process.stdout.write(text);
|
|
31
|
+
|
|
32
|
+
function cleanLine(text, width) {
|
|
33
|
+
const rows = [];
|
|
34
|
+
for (const part of String(text ?? '').split('\n')) {
|
|
35
|
+
if (!part) { rows.push(''); continue; }
|
|
36
|
+
let current = ''; let currentWidth = 0; let escape = false;
|
|
37
|
+
for (const char of part) {
|
|
38
|
+
if (escape) { current += char; if (char === 'm') escape = false; continue; }
|
|
39
|
+
if (char === '\x1b') { current += char; escape = true; continue; }
|
|
40
|
+
const charWidth = visibleWidth(char);
|
|
41
|
+
if (currentWidth + charWidth > width && current) {
|
|
42
|
+
rows.push(current); current = char; currentWidth = charWidth;
|
|
43
|
+
} else { current += char; currentWidth += charWidth; }
|
|
44
|
+
}
|
|
45
|
+
rows.push(current);
|
|
46
|
+
}
|
|
47
|
+
return rows;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function wrapLine(text, width) { return cleanLine(text, Math.max(8, width)); }
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Split raw stdin bytes into complete mouse sequences vs everything else.
|
|
54
|
+
*
|
|
55
|
+
* Node's readline key parser does NOT understand SGR mouse — it shreds
|
|
56
|
+
* `\x1b[<64;10;10M` into junk keypresses (`6`, `4`, `;`, …), so the wheel can
|
|
57
|
+
* never work through the keypress path (and worse, it types garbage into the
|
|
58
|
+
* prompt). The TUI therefore frames mouse bytes here, on the raw stream, and
|
|
59
|
+
* forwards everything else byte-identical to readline.
|
|
60
|
+
*
|
|
61
|
+
* Returns { forward, events, hold }: bytes for readline, parsed mouse events,
|
|
62
|
+
* and a trailing maybe-partial prefix the caller retains for the next chunk.
|
|
63
|
+
*/
|
|
64
|
+
export function splitMouseSequences(buf) {
|
|
65
|
+
const events = [];
|
|
66
|
+
let forward = '';
|
|
67
|
+
let i = 0;
|
|
68
|
+
const sgrRe = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
69
|
+
while (i < buf.length) {
|
|
70
|
+
const esc = buf.indexOf('\x1b', i);
|
|
71
|
+
if (esc === -1) { forward += buf.slice(i); i = buf.length; break; }
|
|
72
|
+
const rest = buf.slice(esc);
|
|
73
|
+
const sgr = sgrRe.exec(rest);
|
|
74
|
+
if (sgr) {
|
|
75
|
+
forward += buf.slice(i, esc);
|
|
76
|
+
events.push({ button: Number(sgr[1]), col: Number(sgr[2]), row: Number(sgr[3]), press: sgr[4] === 'M' });
|
|
77
|
+
i = esc + sgr[0].length;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (rest.startsWith('\x1b[M')) {
|
|
81
|
+
if (rest.length >= 6) {
|
|
82
|
+
forward += buf.slice(i, esc);
|
|
83
|
+
events.push({ button: rest.charCodeAt(3) - 32, col: rest.charCodeAt(4) - 32, row: rest.charCodeAt(5) - 32, press: true, legacy: true });
|
|
84
|
+
i = esc + 6;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
forward += buf.slice(i, esc);
|
|
88
|
+
break; // hold partial X10
|
|
89
|
+
}
|
|
90
|
+
// Terminal focus reports (?1004h): complete 3-byte sequences, never split
|
|
91
|
+
// past the lone-ESC hold below, so they can't leak as junk keypresses.
|
|
92
|
+
if (rest === '\x1b[I' || rest === '\x1b[O') {
|
|
93
|
+
forward += buf.slice(i, esc);
|
|
94
|
+
events.push({ focus: rest === '\x1b[I' });
|
|
95
|
+
i = esc + 3;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Trailing lone ESC / partial CSI (possibly a mouse split across chunks,
|
|
99
|
+
// or a key like PgUp `\x1b[5~` split the same way): hold for more bytes.
|
|
100
|
+
if (/^\x1b(\[[0-9;<=>?]*)?$/.test(rest)) { forward += buf.slice(i, esc); break; }
|
|
101
|
+
forward += buf.slice(i, esc + 1);
|
|
102
|
+
i = esc + 1;
|
|
103
|
+
}
|
|
104
|
+
return { forward, events, hold: buf.slice(i) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── Code highlighting (§9, Tier 1) ───────────────────────────────────────────
|
|
108
|
+
// Small regex tokenizer for fenced blocks in assistant/tool text: keywords in
|
|
109
|
+
// brand coral, strings green, comments dim, numbers yellow, diffs line-tinted.
|
|
110
|
+
// ANSI-safe (styles pre-existing escape runs untouched), toggleable via the
|
|
111
|
+
// `highlight` setting. Not a tree-sitter — a readable terminal, cheaply.
|
|
112
|
+
const CODE_WORDS = {
|
|
113
|
+
js: 'const let var function return if else for while do switch case break continue new class extends import export from default try catch finally throw async await typeof instanceof in of null undefined true false this super static get set yield delete void',
|
|
114
|
+
py: 'def return if elif else for while in not and or is None True False class import from as with lambda try except finally raise pass global nonlocal assert del yield async await',
|
|
115
|
+
bash: 'if then else elif fi for while do done case esac function select in time echo cd ls grep awk sed cat exit return export local readonly shift trap sudo',
|
|
116
|
+
};
|
|
117
|
+
const CODE_LANG = {
|
|
118
|
+
javascript: 'js', typescript: 'js', js: 'js', jsx: 'js', tsx: 'js', ts: 'js', json: 'js',
|
|
119
|
+
python: 'py', py: 'py', shell: 'bash', sh: 'bash', bash: 'bash', zsh: 'bash',
|
|
120
|
+
};
|
|
121
|
+
const CODE_COMMENT = { js: '//', py: '#', bash: '#' };
|
|
122
|
+
const CODE_TOKEN = /("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\\n]|\\.)*`|\/\/[^\n]*|#[^\n]*|\b\d+(?:\.\d+)?\b|\b[A-Za-z_][A-Za-z0-9_]*\b|.)/gs;
|
|
123
|
+
const ANSI_RUN = /\x1b\[[0-9;]*m/g;
|
|
124
|
+
const ANSI_SPLIT = /(\x1b\[[0-9;]*m)/g;
|
|
125
|
+
|
|
126
|
+
function highlightEnabled() {
|
|
127
|
+
try { return loadSettings().highlight !== false; } catch { return true; }
|
|
128
|
+
}
|
|
129
|
+
function fenceOf(line, current) {
|
|
130
|
+
const m = /^\s*```(\w*)\s*$/.exec(String(line ?? '').replace(ANSI_RUN, ''));
|
|
131
|
+
if (!m) return current;
|
|
132
|
+
if (current) return null;
|
|
133
|
+
return CODE_LANG[(m[1] || '').toLowerCase()] || (m[1] ? 'txt' : null);
|
|
134
|
+
}
|
|
135
|
+
function paintDiffLine(text) {
|
|
136
|
+
const t = text;
|
|
137
|
+
if (/^\s*diff --git/.test(t) || /^\s*@@/.test(t)) return c.cyan(t);
|
|
138
|
+
if (/^\s*\+\+\+|^\s*---/.test(t)) return c.bold(t);
|
|
139
|
+
if (/^\s*\+/.test(t)) return c.green(t);
|
|
140
|
+
if (/^\s*-/.test(t)) return c.red(t);
|
|
141
|
+
return t;
|
|
142
|
+
}
|
|
143
|
+
function highlightCodeText(text, lang) {
|
|
144
|
+
const words = new Set((CODE_WORDS[lang] || '').split(' '));
|
|
145
|
+
const hashComment = CODE_COMMENT[lang] === '#';
|
|
146
|
+
return String(text).replace(CODE_TOKEN, (tok) => {
|
|
147
|
+
if (!tok) return tok;
|
|
148
|
+
if (tok.startsWith('"') || tok.startsWith("'") || tok.startsWith('`')) return c.green(tok);
|
|
149
|
+
if (tok.startsWith('//') && CODE_COMMENT[lang] === '//') return c.dim(tok);
|
|
150
|
+
if (tok.startsWith('#') && hashComment) return c.dim(tok);
|
|
151
|
+
if (/^\d/.test(tok)) return c.yellow(tok);
|
|
152
|
+
// Keywords wear the teal accent, never the red-family brand: brand red
|
|
153
|
+
// is reserved for errors and omissions.
|
|
154
|
+
if (words.has(tok)) return c.accent(tok);
|
|
155
|
+
return tok;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function highlightLine(line, fence, kind) {
|
|
159
|
+
if (!highlightEnabled()) return line;
|
|
160
|
+
if (kind !== 'assistant' && kind !== 'tool') return line;
|
|
161
|
+
if (colorLevel() === 0) return line;
|
|
162
|
+
if (fence === 'diff') return String(line).split('\n').map(paintDiffLine).join('\n');
|
|
163
|
+
if (fence && CODE_WORDS[fence]) {
|
|
164
|
+
// Highlight text runs only; pre-existing escape sequences pass through.
|
|
165
|
+
return String(line).split(ANSI_SPLIT).map((seg, i) => (i % 2 === 0 ? highlightCodeText(seg, fence) : seg)).join('');
|
|
166
|
+
}
|
|
167
|
+
if (!fence) {
|
|
168
|
+
// No bare-diff guessing outside ```diff fences: a `- ` markdown bullet
|
|
169
|
+
// is not a removed line, and painting every list red was exactly wrong.
|
|
170
|
+
// `inline code` in prose.
|
|
171
|
+
const plain = String(line).replace(ANSI_RUN, '');
|
|
172
|
+
if (plain.includes('`')) {
|
|
173
|
+
return String(line).split(ANSI_SPLIT).map((seg, i) => {
|
|
174
|
+
if (i % 2 === 1) return seg;
|
|
175
|
+
return seg.split(/(`[^`\n]+`)/g).map((part) => (part.startsWith('`') ? c.cyan(part) : part)).join('');
|
|
176
|
+
}).join('');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return line;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Emoji shortcodes (§3.6): `:name:` live-replaces on the closing colon.
|
|
183
|
+
const EMOJI = {
|
|
184
|
+
heart: '❤', thumbsup: '👍', thumbsdown: '👎', laugh: '😄', smile: '😊', wink: '😉',
|
|
185
|
+
fire: '🔥', star: '⭐', check: '✅', x: '❌', warning: '⚠️', info: 'ℹ️',
|
|
186
|
+
rocket: '🚀', tada: '🎉', eyes: '👀', thinking: '🤔', confused: '😕', cry: '😢',
|
|
187
|
+
clap: '👏', pray: '🙏', muscle: '💪', bug: '🐛', wrench: '🔧', package: '📦',
|
|
188
|
+
book: '📖', pencil: '✏️', memo: '📝', bulb: '💡', lock: '🔒', key: '🔑',
|
|
189
|
+
zap: '⚡', sparkles: '✨', white_check_mark: '✅', red_circle: '🔴', green_circle: '🟢',
|
|
190
|
+
arrow_right: '➡️', arrow_up: '⬆️', recycle: '♻️', wave: '👋', coffee: '☕',
|
|
191
|
+
computer: '💻', terminal: '💻', gear: '⚙️', hammer: '🔨', test_tube: '🧪',
|
|
192
|
+
party: '🥳', grimacing: '😬', sweat: '😅', sleep: '😴', poop: '💩',
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export function menuItemsFor(input, slashCommands, fileProvider) {
|
|
196
|
+
const items = [];
|
|
197
|
+
const slash = /(?:^|\s)\/([^\s]*)$/.exec(input);
|
|
198
|
+
if (slash) {
|
|
199
|
+
const fragment = slash[1].toLowerCase();
|
|
200
|
+
for (const command of slashCommands) {
|
|
201
|
+
const name = command.replace(/^\//, '');
|
|
202
|
+
if (name.toLowerCase().startsWith(fragment)) items.push({ kind: 'command', value: `/${name}`, label: `/${name}`, hint: '' });
|
|
203
|
+
if (items.length === 8) break;
|
|
204
|
+
}
|
|
205
|
+
return items;
|
|
206
|
+
}
|
|
207
|
+
const at = input.lastIndexOf('@');
|
|
208
|
+
if (at >= 0 && !/\s/.test(input.slice(at + 1))) {
|
|
209
|
+
for (const file of (fileProvider?.(input.slice(at + 1)) || []).slice(0, 8)) items.push({ kind: 'file', value: file, label: file, hint: '' });
|
|
210
|
+
}
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function modeLabel(mode) {
|
|
215
|
+
if (mode === 'acceptEdits') return 'accept edits';
|
|
216
|
+
if (mode === 'bypassPermissions') return 'bypass';
|
|
217
|
+
if (mode === 'plan') return 'plan';
|
|
218
|
+
return 'manual';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The composer is an opencode-style left-border panel: a mode-colored │
|
|
222
|
+
// beside every row, input on the bar, no box, no › prefix.
|
|
223
|
+
// Prefixes are 4 cells wide — color codes take no screen cells.
|
|
224
|
+
const PROMPT_PREFIX_WIDTH = 4;
|
|
225
|
+
const PROMPT_USABLE_SLACK = PROMPT_PREFIX_WIDTH + 1; // usable = width - prefix - 1 guard col
|
|
226
|
+
function boxChars() {
|
|
227
|
+
return process.env.JAITUS_ASCII ? { v: '|', tl: '+', tr: '+', bl: '+', br: '+', h: '-' } : { v: '│', tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─' };
|
|
228
|
+
}
|
|
229
|
+
/** Edge styler for the input box (§5.1 tokens): shell cyan, plan amber,
|
|
230
|
+
// bypass red, acceptedits teal, default neutral. */
|
|
231
|
+
function edgeStyle(mode, shell) {
|
|
232
|
+
if (shell) return c.cyan;
|
|
233
|
+
if (mode === 'plan') return c.permission;
|
|
234
|
+
if (mode === 'bypassPermissions') return c.red;
|
|
235
|
+
if (mode === 'acceptEdits') return c.accent;
|
|
236
|
+
return c.grey;
|
|
237
|
+
}
|
|
238
|
+
function promptEdge(mode = 'default', shell = false) {
|
|
239
|
+
const { v } = boxChars();
|
|
240
|
+
return ` ${edgeStyle(mode, shell)(v)} `;
|
|
241
|
+
}
|
|
242
|
+
function promptPrefix(mode = 'default', shell = false) {
|
|
243
|
+
return promptEdge(mode, shell);
|
|
244
|
+
}
|
|
245
|
+
function promptCont(mode = 'default', shell = false) {
|
|
246
|
+
return promptEdge(mode, shell);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function promptRows(input, width, mode = 'default') {
|
|
250
|
+
const usable = Math.max(12, width - PROMPT_USABLE_SLACK);
|
|
251
|
+
const shell = String(input).startsWith('!');
|
|
252
|
+
if (!input) return [`${promptPrefix(mode, shell)}${c.dim(`Ask anything. "${startupTip()}"`)}`];
|
|
253
|
+
return cleanLine(input, usable).map((row, index) => `${index ? promptCont(mode, shell) : promptPrefix(mode, shell)}${row}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function composerTopRow(width, mode, model, shell) {
|
|
257
|
+
const { tl, tr, h } = boxChars();
|
|
258
|
+
const edge = edgeStyle(mode, shell);
|
|
259
|
+
const innerWidth = Math.max(8, width - 5);
|
|
260
|
+
const label = ` ${shell ? 'shell' : 'ask'} · ${modeLabel(mode)}${model ? ` · ${model}` : ''} `;
|
|
261
|
+
const safeLabel = label.slice(0, innerWidth);
|
|
262
|
+
return ` ${edge(tl)}${edge(safeLabel)}${edge(h.repeat(Math.max(0, innerWidth - visibleWidth(safeLabel))))}${edge(tr)}`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function composerBottomRow(width, mode, shell) {
|
|
266
|
+
const { bl, br, h } = boxChars();
|
|
267
|
+
const edge = edgeStyle(mode, shell);
|
|
268
|
+
return ` ${edge(bl)}${edge(h.repeat(Math.max(8, width - 5)))}${edge(br)}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The meta row pinned inside the composer panel: mode posture, model,
|
|
272
|
+
* build marker — opencode's `Agent · model`, plus our version stamp. */
|
|
273
|
+
function promptMetaRow(width, mode = 'default', model = '', shell = false, version = '') {
|
|
274
|
+
const bar = edgeStyle(mode, shell)(glyph('│', '|'));
|
|
275
|
+
const bits = [edgeStyle(mode, shell)(modeLabel(mode))];
|
|
276
|
+
if (model) bits.push(c.dim('·'), model);
|
|
277
|
+
if (version) bits.push(c.dim('·'), c.dim(`v${version}`));
|
|
278
|
+
return ` ${bar} ${bits.join(' ')}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Full-width quiet fill for panel rows (composer, permission) — opencode
|
|
282
|
+
* panels sit on backgroundElement; Jaitus reuses the theme's userBg token
|
|
283
|
+
* so light/daltonized/ocean stay coherent. Pads to the frame width. */
|
|
284
|
+
function panelFill(line, w) {
|
|
285
|
+
if (colorLevel() === 0) return line;
|
|
286
|
+
let codes = null;
|
|
287
|
+
try { codes = themeMap().userBg; } catch { /* plain */ }
|
|
288
|
+
if (!codes) return line;
|
|
289
|
+
const target = Math.max(0, w - 1);
|
|
290
|
+
const pad = ' '.repeat(Math.max(0, target - visibleWidth(line)));
|
|
291
|
+
return `\x1b[${codes}m${line}${pad}\x1b[0m`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Where inside the prompt rows the cursor sits — same usable width, same wrapping. */
|
|
295
|
+
function promptCursor(input, cursor, width) {
|
|
296
|
+
const usable = Math.max(12, width - PROMPT_USABLE_SLACK);
|
|
297
|
+
const before = cleanLine(input.slice(0, cursor), usable);
|
|
298
|
+
// +1: the CUP column is 1-based and the caret sits AFTER the last rendered cell.
|
|
299
|
+
// Prefix is ` │ ` = 4 cells (edge is styled, zero-width codes aside).
|
|
300
|
+
return { row: Math.max(0, before.length - 1), column: PROMPT_PREFIX_WIDTH + visibleWidth(before.at(-1) || '') + 1 };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function menuRows(menu, menuIndex, width) {
|
|
304
|
+
return menu.map((item, index) => {
|
|
305
|
+
const selected = index === menuIndex;
|
|
306
|
+
const kind = c.dim(item.kind === 'command' ? 'command' : 'file');
|
|
307
|
+
const line = ` ${selected ? c.brand('›') : ' '} ${selected ? c.bold(item.label) : item.label} ${kind}`;
|
|
308
|
+
// Pad the selected row for inverse video — but never to the full width (invariant 1).
|
|
309
|
+
return selected ? c.inverse(`${line}${' '.repeat(Math.max(0, width - 1 - visibleWidth(line)))}`) : line;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function composerRows({ input, menu, menuIndex, status, queued, width, mode, model = '', version = '' }) {
|
|
314
|
+
const rows = [];
|
|
315
|
+
if (menu.length) rows.push(...menuRows(menu, menuIndex, width));
|
|
316
|
+
if (queued.length) rows.push(c.dim(` ↳ queued ${queued.length}: ${queued[0].slice(0, Math.max(12, width - 18))}`));
|
|
317
|
+
const shell = String(input).startsWith('!');
|
|
318
|
+
rows.push(panelFill(composerTopRow(width, mode, model, shell), width));
|
|
319
|
+
rows.push(...promptRows(input, width, mode).map((r) => panelFill(r, width)));
|
|
320
|
+
rows.push(panelFill(promptMetaRow(width, mode, model, shell, version), width));
|
|
321
|
+
rows.push(status || c.dim(` ↵ send ⇧↹ ${modeLabel(mode)} PgUp/PgDn scroll / commands @ files`));
|
|
322
|
+
rows.push(panelFill(composerBottomRow(width, mode, shell), width));
|
|
323
|
+
return rows;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 0-based offset of the comment row inside dialogRows (-1 when hidden). */
|
|
327
|
+
function dialogCommentOffset(dialog, width) {
|
|
328
|
+
if (dialog.hideComment) return -1;
|
|
329
|
+
const qLines = dialogQLines(dialog, width);
|
|
330
|
+
// Pills panels drop the divider row the list layout starts with.
|
|
331
|
+
return (isPills(dialog) ? 1 : 2) + qLines + (dialog.preview?.length ? 1 + Math.min(7, dialog.preview.length) : 0);
|
|
332
|
+
}
|
|
333
|
+
/** Question rows inside dialogRows, shared by render, cursor and click math. */
|
|
334
|
+
function dialogQLines(dialog, width) {
|
|
335
|
+
// Icon panels prefix the first row, so the text wraps narrower.
|
|
336
|
+
if (dialog.icon) return cleanLine(dialog.question, Math.max(12, width - 8)).length;
|
|
337
|
+
return cleanLine(` ${dialog.question}`, Math.max(12, width - 4)).length;
|
|
338
|
+
}
|
|
339
|
+
/** Inline option pills (the opencode permission shape) instead of the list. */
|
|
340
|
+
function isPills(dialog) {
|
|
341
|
+
return !!dialog?.pills && dialog.options.length <= 3;
|
|
342
|
+
}
|
|
343
|
+
/** ASCII twins for the panel icon set. */
|
|
344
|
+
const ICON_TWINS = { '→': '>', '←': '<', '✱': '*', '%': '%', '◈': 'o', '#': '#', '⚙': '*', '◉': 'o' };
|
|
345
|
+
const iconFor = (ch) => glyph(ch, ICON_TWINS[ch] || '?');
|
|
346
|
+
/** Visible column spans of each pill (1-based), for click/hover hit-testing. */
|
|
347
|
+
function dialogPillSpans(dialog) {
|
|
348
|
+
const spans = [];
|
|
349
|
+
let col = 5; // ` │ ` occupies cols 1-4
|
|
350
|
+
dialog.options.forEach((option, i) => {
|
|
351
|
+
const w = visibleWidth(` ${option.label} `);
|
|
352
|
+
if (i > 0) col += 1; // gap
|
|
353
|
+
spans.push({ start: col, end: col + w - 1, index: i });
|
|
354
|
+
col += w;
|
|
355
|
+
});
|
|
356
|
+
return spans;
|
|
357
|
+
}
|
|
358
|
+
function dialogPillRow(dialog) {
|
|
359
|
+
const bar = c.permission(glyph('│', '|'));
|
|
360
|
+
// Selected pill glows amber-bold; the rest stay dim. No inverse video —
|
|
361
|
+
// inverse closes would shred the panel's background fill.
|
|
362
|
+
const pills = dialog.options.map((option, i) =>
|
|
363
|
+
i === dialog.index ? c.bold(c.permission(` ${option.label} `)) : c.dim(` ${option.label} `)).join(' ');
|
|
364
|
+
return ` ${bar} ${pills}`;
|
|
365
|
+
}
|
|
366
|
+
function dialogRows(dialog, width) {
|
|
367
|
+
if (isPills(dialog)) {
|
|
368
|
+
const bar = c.permission(glyph('│', '|'));
|
|
369
|
+
const title = dialog.title || 'Permission required';
|
|
370
|
+
const rows = [
|
|
371
|
+
` ${bar} ${c.permission(glyph('△', '!'))} ${c.bold(title)}`,
|
|
372
|
+
];
|
|
373
|
+
cleanLine(dialog.question, Math.max(12, width - 8)).forEach((line, i) =>
|
|
374
|
+
rows.push(` ${bar} ${i === 0 && dialog.icon ? `${iconFor(dialog.icon)} ` : ''}${line}`));
|
|
375
|
+
if (dialog.preview?.length) {
|
|
376
|
+
rows.push(` ${bar} ${c.dim('Preview')}`);
|
|
377
|
+
for (const line of dialog.preview.slice(0, 7)) rows.push(` ${bar} ${String(line).slice(0, Math.max(8, width - 10))}`);
|
|
378
|
+
}
|
|
379
|
+
// Inline comment the model reads with the decision (§11): `e` to write one.
|
|
380
|
+
if (!dialog.hideComment) {
|
|
381
|
+
const hint = dialog.editing ? '▏' : '';
|
|
382
|
+
rows.push(dialog.comment
|
|
383
|
+
? ` ${bar} ${c.permission(glyph('✎', 'e'))} ${dialog.comment}${hint}`
|
|
384
|
+
: c.dim(` ${glyph('│', '|')} ${glyph('✎', 'e')} ${dialog.editing ? '' : 'e to explain'}${hint}`));
|
|
385
|
+
}
|
|
386
|
+
rows.push(dialogPillRow(dialog));
|
|
387
|
+
rows.push(c.dim(` ${glyph('│', '|')} ${glyph('⇆', '<->')} select · enter confirm · e explain · esc deny`));
|
|
388
|
+
// The whole panel sits on the quiet fill, opencode-style.
|
|
389
|
+
return rows.map((r) => panelFill(r, width));
|
|
390
|
+
}
|
|
391
|
+
const title = dialog.title || `${c.permission('!')} ${c.bold('Permission required')}`;
|
|
392
|
+
const rows = [
|
|
393
|
+
c.permission(` ${'─'.repeat(Math.max(8, width - 4))}`),
|
|
394
|
+
` ${title}`,
|
|
395
|
+
...cleanLine(` ${dialog.question}`, Math.max(12, width - 4)),
|
|
396
|
+
];
|
|
397
|
+
if (dialog.preview?.length) {
|
|
398
|
+
rows.push(c.dim(' Preview'));
|
|
399
|
+
for (const line of dialog.preview.slice(0, 7)) rows.push(` ${c.grey('│')} ${String(line).slice(0, Math.max(8, width - 8))}`);
|
|
400
|
+
}
|
|
401
|
+
// Inline comment the model reads with the decision (§11): `e` to write one.
|
|
402
|
+
// Plain pickers hide it so the list stays clean.
|
|
403
|
+
if (!dialog.hideComment) {
|
|
404
|
+
const hint = dialog.editing ? '▏' : '';
|
|
405
|
+
rows.push(dialog.comment
|
|
406
|
+
? ` ${c.permission('✎')} ${dialog.comment}${hint}`
|
|
407
|
+
: c.dim(` ✎ ${dialog.editing ? '' : 'e to explain'}${hint}`));
|
|
408
|
+
}
|
|
409
|
+
// Long lists scroll inside the dialog: 9 visible rows around the cursor.
|
|
410
|
+
const total = dialog.options.length;
|
|
411
|
+
const top = total <= 9 ? 0 : Math.max(0, Math.min(total - 9, dialog.index - 4));
|
|
412
|
+
const shown = Math.min(9, total - top);
|
|
413
|
+
if (top > 0) rows.push(c.dim(` … ${top} more above`));
|
|
414
|
+
for (let k = 0; k < shown; k++) {
|
|
415
|
+
const index = top + k;
|
|
416
|
+
const option = dialog.options[index];
|
|
417
|
+
// The cursor row wears inverse video like the composer menu — padded to
|
|
418
|
+
// the width but never beyond it (renderer invariant 1).
|
|
419
|
+
const line = ` ${index === dialog.index ? c.brand('›') : ' '} ${index === dialog.index ? c.bold(option.label) : option.label}`;
|
|
420
|
+
rows.push(index === dialog.index
|
|
421
|
+
? c.inverse(`${line}${' '.repeat(Math.max(0, width - 1 - visibleWidth(line)))}`)
|
|
422
|
+
: line);
|
|
423
|
+
}
|
|
424
|
+
if (top + shown < total) rows.push(c.dim(` … ${total - top - shown} more below`));
|
|
425
|
+
rows.push(c.dim(dialog.hideComment
|
|
426
|
+
? ` ↑↓ choose · 1-9 jump${total > 9 ? ' · list scrolls' : ''} · Enter confirm · Esc cancel`
|
|
427
|
+
: ' ↑↓ choose · e explain · Enter confirm · Esc deny'));
|
|
428
|
+
return rows;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
function historySearchRows(search, width) {
|
|
433
|
+
return [
|
|
434
|
+
c.grey(` ${'─'.repeat(Math.max(8, width - 4))}`),
|
|
435
|
+
` ${c.brand('⌕')} ${c.bold('Reverse history')} ${c.dim(search.query || 'type to search')}`,
|
|
436
|
+
...search.matches.slice(0, 5).map((entry, index) => ` ${index === search.index ? c.brand('›') : ' '} ${index === search.index ? c.bold(entry.replace(/\n/g, ' ↵ ')) : entry.replace(/\n/g, ' ↵ ')}`),
|
|
437
|
+
c.dim(' ↑↓ choose Enter insert Esc cancel'),
|
|
438
|
+
];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Exported for layout tests: the pure lower-region rows, each hard-capped below width.
|
|
442
|
+
export function renderRegion({ input = '', cursor = 0, menu = [], menuIndex = -1, status = null, width = 80, hint = '', queued = [], mode = 'default', model = '', version = '' }) {
|
|
443
|
+
const rows = composerRows({ input, menu, menuIndex, status: status || (hint ? c.dim(` ${hint}`) : null), queued, width, mode, model, version });
|
|
444
|
+
return rows.map((row) => visibleWidth(row) > width - 2 ? cleanLine(row, width - 2)[0] : row);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function createTui({ slashCommands = [], fileProvider = null, meta = {}, onModeCycle = null, onRewind = null } = {}) {
|
|
448
|
+
// Mouse capture in fullscreen: wheel + click/drag scroll and select inside the
|
|
449
|
+
// app, with env opt-outs that keep flicker-free rendering. JAITUS_DISABLE_MOUSE=1
|
|
450
|
+
// disables all capture so the terminal selects natively; JAITUS_DISABLE_MOUSE_CLICKS=1
|
|
451
|
+
// keeps wheel scrolling but ignores click/drag/hover. Copy-on-select defaults on,
|
|
452
|
+
// toggle via settings.json { "copyOnSelect": false } or /config.
|
|
453
|
+
const mouseDisabled = () =>
|
|
454
|
+
process.env.JAITUS_DISABLE_MOUSE === '1';
|
|
455
|
+
const mouseClicksDisabled = () =>
|
|
456
|
+
mouseDisabled() || process.env.JAITUS_DISABLE_MOUSE_CLICKS === '1';
|
|
457
|
+
const copyOnSelect = () => {
|
|
458
|
+
try {
|
|
459
|
+
return loadSettings().copyOnSelect !== false;
|
|
460
|
+
} catch { /* default on */ }
|
|
461
|
+
return true;
|
|
462
|
+
};
|
|
463
|
+
const scrollSpeed = () => {
|
|
464
|
+
try {
|
|
465
|
+
const s = loadSettings().scrollSpeed;
|
|
466
|
+
if (Number.isFinite(Number(s)) && Number(s) > 0 && Number(s) <= 20) return Number(s);
|
|
467
|
+
} catch { /* env fallback */ }
|
|
468
|
+
const raw = Number(process.env.JAITUS_SCROLL_SPEED ?? 1);
|
|
469
|
+
return Number.isFinite(raw) && raw > 0 && raw <= 20 ? raw : 1;
|
|
470
|
+
};
|
|
471
|
+
const state = {
|
|
472
|
+
input: '', cursor: 0, menu: [], menuIndex: -1, history: [], historyIndex: -1, draft: null,
|
|
473
|
+
queued: [], busy: false, statusText: null, customStatus: '', dialog: null, panel: null, commandPalette: null, activity: [], transcript: [], liveText: '', liveKind: null, liveUnseen: false,
|
|
474
|
+
transcriptOffset: 0, newMessages: 0, viewingTranscript: false, search: null, lastSearch: '', historySearch: null, shouldExit: false, exiting: null,
|
|
475
|
+
lastEscapeAt: null,
|
|
476
|
+
// In-app text selection: anchor/head are content
|
|
477
|
+
// coordinates { line, col } into the visible transcript content array.
|
|
478
|
+
selection: null, selectionMode: null,
|
|
479
|
+
lastClickAt: 0, lastClickPos: null, clickCount: 0,
|
|
480
|
+
// Readline editing state (§3.2): undo ring, kill ring, one-slot stash.
|
|
481
|
+
undoStack: [], redoStack: [], killRing: [], killIndex: -1, lastKill: '', stash: null,
|
|
482
|
+
// Vim modal editing (§3.2): off unless settings editorMode is 'vim'.
|
|
483
|
+
// vim: 'insert' | 'normal' | 'visual'; op: pending operator; num: count.
|
|
484
|
+
vim: 'insert', vimOp: null, vimNum: '', vimAnchor: 0, vimReplace: false,
|
|
485
|
+
vimPendingG: false, vimPendingObj: null, vimEx: null,
|
|
486
|
+
vimFind: null, vimFindTyping: false,
|
|
487
|
+
vimSession: 0, vimInsertActive: false, lastChange: null,
|
|
488
|
+
yankPos: -1, yankLen: 0, yankIndex: -1,
|
|
489
|
+
// Paste chips (§3.4): id → full pasted text; the input holds only the chip.
|
|
490
|
+
pastes: new Map(), pasteSeq: 0,
|
|
491
|
+
// Image chips (§3.5): blocks queued for the next submit, parallel to pastes.
|
|
492
|
+
attachments: [],
|
|
493
|
+
// Focus tracking (§15): true while the terminal reports focus-out (?1004h).
|
|
494
|
+
unfocused: false, awaySince: 0, awayNew: 0,
|
|
495
|
+
// Wheel acceleration (§4.2): fast spins cover more ground per notch.
|
|
496
|
+
lastWheelAt: 0, wheelStreak: 0,
|
|
497
|
+
// Animated working state (§8): frame tick + cached motion preference.
|
|
498
|
+
busyTick: 0, busyAnim: true,
|
|
499
|
+
meta: { product: 'jaitus', version: '', cwd: process.cwd(), model: '', mode: 'default', intent: 'build', ...meta },
|
|
500
|
+
// Home column: true from fresh open until first submit — the composer
|
|
501
|
+
// renders narrow and centered, opencode-home style. Set by the session.
|
|
502
|
+
home: false,
|
|
503
|
+
onLine: null, onEscape: null, onRewind: null,
|
|
504
|
+
};
|
|
505
|
+
const stdin = process.stdin;
|
|
506
|
+
let active = false; let onKeypress = null; let onResize = null;
|
|
507
|
+
let keyFilter = null; let onRawData = null; let mouseFlushTimer = null; let busyTimer = null;
|
|
508
|
+
// The widest visible row we will ever emit — two columns of slack against wrap.
|
|
509
|
+
const width = () => Math.max(28, (columns() || 80) - 2);
|
|
510
|
+
const height = () => Math.max(12, process.stdout.rows || 24);
|
|
511
|
+
|
|
512
|
+
// The browser preview's command centre is responsive in the same way: the
|
|
513
|
+
// session rail survives on ordinary terminals, the telemetry rail joins when
|
|
514
|
+
// there is room, and only genuinely small terminals collapse to one column.
|
|
515
|
+
function columnMetrics(w = width()) {
|
|
516
|
+
if (w < 96) return { left: 0, right: 0, center: w, divider: 0 };
|
|
517
|
+
const left = 30;
|
|
518
|
+
const right = w < 140 ? 0 : 30;
|
|
519
|
+
const divider = 1;
|
|
520
|
+
const dividerCount = (left ? 1 : 0) + (right ? 1 : 0);
|
|
521
|
+
return { left, right, divider, center: Math.max(52, w - left - right - dividerCount) };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function contentWidth(w = width()) { return columnMetrics(w).center; }
|
|
525
|
+
|
|
526
|
+
function padFrame(text, target, align = 'left') {
|
|
527
|
+
const value = String(text ?? '');
|
|
528
|
+
const clipped = visibleWidth(value) > target ? cleanLine(value, Math.max(1, target - 1))[0] : value;
|
|
529
|
+
const gap = Math.max(0, target - visibleWidth(clipped));
|
|
530
|
+
return align === 'right' ? `${' '.repeat(gap)}${clipped}` : `${clipped}${' '.repeat(gap)}`;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function frameRow(left, center, right, metrics) {
|
|
534
|
+
if (!metrics.left && !metrics.right) return center;
|
|
535
|
+
const leftPart = metrics.left ? `${padFrame(left, metrics.left)}${c.grey('│')}` : '';
|
|
536
|
+
const rightPart = metrics.right ? `${c.grey('│')}${padFrame(right, metrics.right, 'left')}` : '';
|
|
537
|
+
const centerWidth = metrics.center;
|
|
538
|
+
return `${leftPart}${padFrame(center, centerWidth)}${rightPart}`;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Transcript entries carry their message kind so each renders in its own
|
|
542
|
+
// quiet background (user / assistant / tool), per §9 — not opaque bubbles,
|
|
543
|
+
// just enough fill to tell voices apart while scrolling fast.
|
|
544
|
+
// Startup brand rows are pinned: trimming drops everything after them, so
|
|
545
|
+
// the logo stays scrollable-back-to for the whole session.
|
|
546
|
+
const PINNED_ROWS = 12;
|
|
547
|
+
function addTranscript(text, kind = 'other') {
|
|
548
|
+
for (const row of cleanLine(text, contentWidth() - 2)) state.transcript.push({ text: row, kind });
|
|
549
|
+
if (state.transcript.length > 2400) state.transcript.splice(PINNED_ROWS, state.transcript.length - 2400);
|
|
550
|
+
}
|
|
551
|
+
function commitLive() {
|
|
552
|
+
if (!state.liveText) return;
|
|
553
|
+
addTranscript(state.liveText, state.liveKind || 'assistant');
|
|
554
|
+
state.liveText = ''; state.liveUnseen = false; state.liveKind = null;
|
|
555
|
+
}
|
|
556
|
+
function updateMenu() {
|
|
557
|
+
if (state.dialog) return;
|
|
558
|
+
state.menu = menuItemsFor(state.input, slashCommands, fileProvider);
|
|
559
|
+
// `!` shell history (§3.8): complete whole past shell lines.
|
|
560
|
+
if (state.input.startsWith('!')) {
|
|
561
|
+
const frag = state.input.slice(1).toLowerCase();
|
|
562
|
+
const seen = new Set();
|
|
563
|
+
for (const h of [...state.history].reverse()) {
|
|
564
|
+
if (state.menu.length >= 8) break;
|
|
565
|
+
if (!h.startsWith('!') || seen.has(h)) continue;
|
|
566
|
+
seen.add(h);
|
|
567
|
+
if (h.slice(1).toLowerCase().startsWith(frag)) state.menu.push({ kind: 'history', value: h, label: h, hint: 'history' });
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// `:xx` emoji shortcodes (§3.6), live-filtered.
|
|
571
|
+
const em = /:([A-Za-z0-9_+-]{2,})$/.exec(state.input);
|
|
572
|
+
if (em) {
|
|
573
|
+
const frag = em[1].toLowerCase();
|
|
574
|
+
for (const [name, ch] of Object.entries(EMOJI)) {
|
|
575
|
+
if (state.menu.length >= 8) break;
|
|
576
|
+
if (name.startsWith(frag)) state.menu.push({ kind: 'emoji', value: ch, label: `:${name}: → ${ch}`, hint: 'emoji' });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
state.menuIndex = state.menu.length ? 0 : -1;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function headerRows(w) {
|
|
583
|
+
const project = path.basename(state.meta.cwd || '') || state.meta.cwd || '.';
|
|
584
|
+
const model = state.meta.model || 'auto';
|
|
585
|
+
const context = state.meta.ctx || 'ready';
|
|
586
|
+
const mode = modeLabel(state.meta.mode);
|
|
587
|
+
const intent = state.meta.intent && state.meta.intent !== 'build' ? ` ${c.accent(state.meta.intent)}` : '';
|
|
588
|
+
const left = ` ${badge()} ${c.bold('jaitus')} ${c.dim(project)}`;
|
|
589
|
+
const session = state.meta.session ? String(state.meta.session).slice(0, 8) : 'active';
|
|
590
|
+
const middle = `${c.accent(glyph('●', '*'))} ${c.dim('SESSION')} ${c.dim(session)}`;
|
|
591
|
+
const right = `${c.dim(model)} ${state.meta.mode === 'bypassPermissions' ? c.red(mode) : c.accent(mode)}${intent} ${c.dim(context)}`;
|
|
592
|
+
const gapLeft = Math.max(1, Math.floor((w - visibleWidth(left) - visibleWidth(middle) - visibleWidth(right)) / 2));
|
|
593
|
+
const gapRight = Math.max(1, w - visibleWidth(left) - visibleWidth(middle) - visibleWidth(right) - gapLeft);
|
|
594
|
+
return [
|
|
595
|
+
`${left}${' '.repeat(gapLeft)}${middle}${' '.repeat(gapRight)}${right}`,
|
|
596
|
+
c.grey(` ${boxChars().h.repeat(Math.max(8, w - 4))}`),
|
|
597
|
+
];
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const PALETTE_COMMANDS = [
|
|
601
|
+
['/research ', 'Research with sources', 'web search + cited synthesis'],
|
|
602
|
+
['/architect ', 'Architecture mode', 'compare designs without edits'],
|
|
603
|
+
['/review ', 'Review the diff', 'bugs, security, maintainability'],
|
|
604
|
+
['/tasks ', 'Task timeline', 'running jobs and checkpoints'],
|
|
605
|
+
['/compact ', 'Compact context', 'free room without losing decisions'],
|
|
606
|
+
['/model ', 'Switch model', 'change the active reasoning engine'],
|
|
607
|
+
];
|
|
608
|
+
|
|
609
|
+
function paletteMatches() {
|
|
610
|
+
const query = String(state.commandPalette?.query || '').toLowerCase();
|
|
611
|
+
return PALETTE_COMMANDS.filter(([value, label, hint]) => `${value} ${label} ${hint}`.toLowerCase().includes(query));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function paletteRows(space, w) {
|
|
615
|
+
const palette = state.commandPalette || { query: '', index: 0 };
|
|
616
|
+
const matches = paletteMatches();
|
|
617
|
+
const rows = [
|
|
618
|
+
c.dim(` ${glyph('⌘', 'Ctrl')}K command palette ${c.dim('Esc close')}`),
|
|
619
|
+
` ${c.brand('/')} ${palette.query || c.dim('Search commands')}`,
|
|
620
|
+
c.grey(` ${boxChars().h.repeat(Math.max(8, w - 4))}`),
|
|
621
|
+
];
|
|
622
|
+
for (const [index, [, label, hint]] of matches.entries()) {
|
|
623
|
+
const selected = index === palette.index;
|
|
624
|
+
const mark = selected ? c.brand(glyph('›', '>')) : ' ';
|
|
625
|
+
const line = ` ${mark} ${selected ? c.bold(label) : label} ${c.dim(hint)}`;
|
|
626
|
+
rows.push(selected ? c.inverse(`${line}${' '.repeat(Math.max(0, w - 1 - visibleWidth(line)))}`) : line);
|
|
627
|
+
}
|
|
628
|
+
rows.push('', c.dim(' ↑↓ navigate Enter insert Esc close'));
|
|
629
|
+
return rows.slice(0, Math.max(1, space));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function leftRailRows(total) {
|
|
633
|
+
const project = path.basename(state.meta.cwd || '') || '.';
|
|
634
|
+
const mode = state.meta.intent || 'build';
|
|
635
|
+
const top = [
|
|
636
|
+
` ${c.brand('+')} ${c.bold('New session')} ${c.dim('⌘ N')}`,
|
|
637
|
+
'',
|
|
638
|
+
` ${c.bold('SESSIONS')} ${c.dim('FILES')} ${c.dim('COMMANDS')}`,
|
|
639
|
+
c.dim(' RECENT SESSIONS'),
|
|
640
|
+
` ${c.accent('●')} ${c.bold(project.slice(0, 17))}`,
|
|
641
|
+
` ${c.dim('current · ' + mode + ' mode')}`,
|
|
642
|
+
` ${c.grey('●')} ${c.dim('Pricing guardrails')}`,
|
|
643
|
+
` ${c.grey('●')} ${c.dim('Provider fallback map')}`,
|
|
644
|
+
'',
|
|
645
|
+
c.dim(' CAPABILITIES'),
|
|
646
|
+
` ${c.accent(glyph('◌', 'o'))} Research ${c.dim('/research')}`,
|
|
647
|
+
` ${c.accent(glyph('◇', '*'))} Review ${c.dim('/review')}`,
|
|
648
|
+
` ${c.accent(glyph('☷', '#'))} Tasks ${c.dim('/tasks')}`,
|
|
649
|
+
` ${c.accent(glyph('⚡', '*'))} MCP tools ${c.dim('/mcp')}`,
|
|
650
|
+
];
|
|
651
|
+
const footer = [
|
|
652
|
+
'',
|
|
653
|
+
c.dim(' SETTINGS'),
|
|
654
|
+
` ${c.dim(glyph('⚙', '*'))} Settings ${c.dim('⌘ ,')}`,
|
|
655
|
+
` ${c.dim(glyph('◉', 'o'))} Permissions ${state.meta.mode === 'bypassPermissions' ? c.red('BYPASS') : c.dim('active')}`,
|
|
656
|
+
];
|
|
657
|
+
const body = Array(Math.max(0, total - top.length - footer.length)).fill('');
|
|
658
|
+
return [...top, ...body, ...footer].slice(0, total);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function inspectorRows(total) {
|
|
662
|
+
const activity = state.activity.slice(-4).reverse();
|
|
663
|
+
const busy = state.busy ? c.brand('LIVE') : c.accent('READY');
|
|
664
|
+
const rows = [
|
|
665
|
+
c.dim(' SESSION INSPECTOR'),
|
|
666
|
+
` ${c.bold('Live telemetry')} ${c.dim('☷')}`,
|
|
667
|
+
'',
|
|
668
|
+
` ${c.dim('CONTEXT')} ${c.bold(state.meta.ctx || 'ready')}`,
|
|
669
|
+
` ${c.dim('MODE')} ${c.accent(state.meta.intent || 'build')} ${busy}`,
|
|
670
|
+
'',
|
|
671
|
+
c.dim(' ACTIVE TOOLS'),
|
|
672
|
+
];
|
|
673
|
+
if (!activity.length) rows.push(` ${c.dim('· no tools yet')}`);
|
|
674
|
+
for (const item of activity) {
|
|
675
|
+
const mark = item.status === 'ok' ? c.accent('●') : item.status === 'failed' ? c.red('●') : c.brand('●');
|
|
676
|
+
rows.push(` ${mark} ${String(item.name || item.label).slice(0, 15)} ${c.dim(item.status || 'ready')}`);
|
|
677
|
+
}
|
|
678
|
+
rows.push('', c.dim(' MODE CONTROL'), ` ${state.meta.intent === 'build' ? c.brand('Build') : c.dim('Build')} ${state.meta.intent === 'ask' ? c.brand('Ask') : c.dim('Ask')} ${state.meta.intent === 'architect' ? c.brand('Architect') : c.dim('Architect')}`);
|
|
679
|
+
rows.push('', c.dim(' VISIBLE APPROVALS'), ` ${state.meta.mode === 'bypassPermissions' ? c.red('● bypass active') : c.accent('● approval boundary active')}`);
|
|
680
|
+
return rows.slice(0, Math.max(1, total));
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function sessionIntroRows(w) {
|
|
684
|
+
const project = path.basename(state.meta.cwd || '') || '.';
|
|
685
|
+
const intent = state.meta.intent || 'build';
|
|
686
|
+
return [
|
|
687
|
+
` ${c.accent(glyph('●', '*'))} ${c.dim('ACTIVE SESSION')}`,
|
|
688
|
+
` ${c.bold(project)} ${c.dim('Trace the flow, find the risk, make the next move visible.')}`,
|
|
689
|
+
'',
|
|
690
|
+
c.dim(` Session restored · Jaitus workspace · ${state.activity.length || 0} tools ready`),
|
|
691
|
+
c.dim(` intent ${intent} · ${state.meta.model || 'auto'}`),
|
|
692
|
+
].map((line) => padFrame(line, w));
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Terminal-correct bypass key for one-off native selection. */
|
|
696
|
+
function nativeSelectKey() {
|
|
697
|
+
if (process.platform === 'darwin') {
|
|
698
|
+
if (process.env.TERM_PROGRAM === 'Apple_Terminal') return 'Fn';
|
|
699
|
+
if (process.env.TERM_PROGRAM === 'iTerm.app') return 'Option';
|
|
700
|
+
if (process.env.TERM_PROGRAM === 'vscode') return 'Shift';
|
|
701
|
+
return 'Option';
|
|
702
|
+
}
|
|
703
|
+
if (process.env.TERM_PROGRAM === 'vscode') return 'Shift';
|
|
704
|
+
return 'Shift';
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function transcriptContent(w) {
|
|
708
|
+
return [...state.transcript.map((e) => e.text), ...(state.liveText ? cleanLine(state.liveText, w - 2) : [])];
|
|
709
|
+
}
|
|
710
|
+
/** Message kind for a content index (live tail counts as assistant). */
|
|
711
|
+
function kindAt(idx) {
|
|
712
|
+
if (idx < state.transcript.length) return state.transcript[idx]?.kind || 'other';
|
|
713
|
+
return state.liveKind || 'assistant';
|
|
714
|
+
}
|
|
715
|
+
/** Quiet full-width background per message kind (§9). Skipped without color,
|
|
716
|
+
* on empty rows, and for kinds without a token — never louder than dim text. */
|
|
717
|
+
function paintRow(line, kind, w) {
|
|
718
|
+
const token = kind === 'error' ? 'errorBg' : kind === 'user' ? 'userBg' : kind === 'assistant' ? 'assistantBg' : kind === 'tool' ? 'toolBg' : kind === 'memory' ? 'memoryBg' : null;
|
|
719
|
+
if (!token || !String(line).trim()) return line;
|
|
720
|
+
if (colorLevel() === 0) return line;
|
|
721
|
+
let codes = null;
|
|
722
|
+
try { codes = themeMap()[token]; } catch { /* plain */ }
|
|
723
|
+
if (!codes) return line;
|
|
724
|
+
const target = Math.max(0, w - 1);
|
|
725
|
+
const pad = ' '.repeat(Math.max(0, target - visibleWidth(line)));
|
|
726
|
+
return `\x1b[${codes}m${line}${pad}\x1b[0m`;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function inSelection(lineIndex, w) {
|
|
730
|
+
const sel = state.selection;
|
|
731
|
+
if (!sel) return null;
|
|
732
|
+
const a = sel.anchor.line <= sel.head.line ? sel.anchor : sel.head;
|
|
733
|
+
const b = sel.anchor.line <= sel.head.line ? sel.head : sel.anchor;
|
|
734
|
+
if (lineIndex < a.line || lineIndex > b.line) return null;
|
|
735
|
+
const startCol = lineIndex === a.line ? a.col : 0;
|
|
736
|
+
const endCol = lineIndex === b.line ? b.col : Number.MAX_SAFE_INTEGER;
|
|
737
|
+
return { startCol, endCol };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function applySelection(line, lineIndex) {
|
|
741
|
+
const range = inSelection(lineIndex);
|
|
742
|
+
if (!range) return line;
|
|
743
|
+
// ANSI-aware slice by visible columns; selected span renders inverse.
|
|
744
|
+
const plain = String(line ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
745
|
+
const chars = [...plain];
|
|
746
|
+
const s = Math.max(0, Math.min(chars.length, range.startCol));
|
|
747
|
+
const e = Math.max(s, Math.min(chars.length, range.endCol));
|
|
748
|
+
if (s >= e) return line;
|
|
749
|
+
return chars.slice(0, s).join('') + c.inverse(chars.slice(s, e).join('')) + chars.slice(e).join('');
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function transcriptRows(space, w) {
|
|
753
|
+
const content = transcriptContent(w);
|
|
754
|
+
if (!content.length && !state.viewingTranscript) return [c.dim(' Start with a task, question, / command, ! shell command, or @ file.')];
|
|
755
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
756
|
+
const start = Math.max(0, end - space);
|
|
757
|
+
const query = state.search?.query || state.lastSearch;
|
|
758
|
+
// Fence state is positional: scan from the top so a block opened above the
|
|
759
|
+
// viewport still highlights inside it.
|
|
760
|
+
let fence = null;
|
|
761
|
+
for (let i = 0; i < start; i++) fence = fenceOf(content[i], fence);
|
|
762
|
+
return content.slice(start, end).map((line, i) => {
|
|
763
|
+
const idx = start + i;
|
|
764
|
+
const kind = kindAt(idx);
|
|
765
|
+
const marked = query && line.toLowerCase().includes(query.toLowerCase()) ? `${c.brand('›')} ${line}` : line;
|
|
766
|
+
// Highlight code, then selection (re-slices plain text), background last.
|
|
767
|
+
const code = highlightLine(marked, fence, kind);
|
|
768
|
+
fence = fenceOf(line, fence);
|
|
769
|
+
const selected = state.selection ? applySelection(code, idx) : code;
|
|
770
|
+
return paintRow(selected, kind, w);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function selectedText() {
|
|
775
|
+
const sel = state.selection;
|
|
776
|
+
if (!sel) return '';
|
|
777
|
+
const w = contentWidth();
|
|
778
|
+
const content = transcriptContent(w).map((l) => String(l ?? '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
779
|
+
const a = sel.anchor.line <= sel.head.line ? sel.anchor : sel.head;
|
|
780
|
+
const b = sel.anchor.line <= sel.head.line ? sel.head : sel.anchor;
|
|
781
|
+
const parts = [];
|
|
782
|
+
for (let i = a.line; i <= b.line && i < content.length; i++) {
|
|
783
|
+
if (i < 0) continue;
|
|
784
|
+
const chars = [...(content[i] || '')];
|
|
785
|
+
const s = i === a.line ? Math.max(0, a.col) : 0;
|
|
786
|
+
const e = i === b.line ? Math.min(chars.length, b.col) : chars.length;
|
|
787
|
+
parts.push(chars.slice(s, Math.max(s, e)).join(''));
|
|
788
|
+
}
|
|
789
|
+
return parts.join('\n').replace(/[ \t]+\n/g, '\n').trim();
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/** Clipboard write: tmux, native tools, then OSC 52; returns the path used or ''. */
|
|
793
|
+
function copyText(text) {
|
|
794
|
+
if (!text) return '';
|
|
795
|
+
// Inside tmux also feed the paste buffer so tmux copy-mode sees it.
|
|
796
|
+
if (process.env.TMUX) {
|
|
797
|
+
try {
|
|
798
|
+
const child = spawnSync('tmux', ['load-buffer', '-'], { input: text });
|
|
799
|
+
if (child.status === 0) return 'tmux';
|
|
800
|
+
} catch { /* fall through to native tools */ }
|
|
801
|
+
}
|
|
802
|
+
const candidates = process.platform === 'win32'
|
|
803
|
+
? [{ cmd: 'clip', args: [] }]
|
|
804
|
+
: process.platform === 'darwin'
|
|
805
|
+
? [{ cmd: 'pbcopy', args: [] }]
|
|
806
|
+
: [{ cmd: 'wl-copy', args: [] }, { cmd: 'xclip', args: ['-selection', 'clipboard'] }, { cmd: 'xsel', args: ['--clipboard', '--input'] }];
|
|
807
|
+
for (const { cmd, args } of candidates) {
|
|
808
|
+
try {
|
|
809
|
+
const child = spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
810
|
+
if (child.status === 0) {
|
|
811
|
+
// X11: also feed PRIMARY so middle-click paste works.
|
|
812
|
+
if (cmd === 'xclip') {
|
|
813
|
+
try { spawnSync('xclip', ['-selection', 'primary'], { input: text, stdio: ['pipe', 'ignore', 'ignore'] }); } catch { /* clipboard already set */ }
|
|
814
|
+
}
|
|
815
|
+
if (cmd === 'xsel') {
|
|
816
|
+
try { spawnSync('xsel', ['--primary', '--input'], { input: text, stdio: ['pipe', 'ignore', 'ignore'] }); } catch { /* clipboard already set */ }
|
|
817
|
+
}
|
|
818
|
+
// Windows/WSL without a console tool: PowerShell Set-Clipboard.
|
|
819
|
+
return cmd;
|
|
820
|
+
}
|
|
821
|
+
} catch { /* next candidate */ }
|
|
822
|
+
}
|
|
823
|
+
if (process.platform === 'win32') {
|
|
824
|
+
try {
|
|
825
|
+
const ps = spawnSync('powershell.exe', ['-NoProfile', '-Command', '$i=[Console]::In.ReadToEnd(); Set-Clipboard -Value $i'], { input: text });
|
|
826
|
+
if (ps.status === 0) return 'clipboard';
|
|
827
|
+
} catch { /* fall to OSC 52 */ }
|
|
828
|
+
}
|
|
829
|
+
// Over SSH / anywhere else: OSC 52. Some terminals block it until
|
|
830
|
+
// "applications may access clipboard" is enabled (iTerm2: /terminal-setup).
|
|
831
|
+
try {
|
|
832
|
+
const b64 = Buffer.from(text, 'utf8').toString('base64');
|
|
833
|
+
rawWrite(`\x1b]52;c;${b64}\x07`);
|
|
834
|
+
return 'OSC 52';
|
|
835
|
+
} catch { return ''; }
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function toastCopied(pathUsed, chars) {
|
|
839
|
+
// Copy confirmations read as a highlighted badge, not footnote dim text.
|
|
840
|
+
state.statusText = chars
|
|
841
|
+
? `${c.inverse(` Copied ${chars} chars via ${pathUsed} `)}${c.dim(` ${nativeSelectKey()}+drag for native select`)}`
|
|
842
|
+
: c.dim(` Clipboard unavailable — [ writes the conversation to scrollback, v opens it in an editor`);
|
|
843
|
+
draw();
|
|
844
|
+
setTimeout(() => { if (state.statusText?.includes('Copied') || state.statusText?.includes('Clipboard unavailable')) { state.statusText = null; draw(); } }, 2200);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** Focus tracking (§15): bell only ever fires while the terminal is away. */
|
|
848
|
+
function onFocus(focused) {
|
|
849
|
+
if (focused && state.unfocused) {
|
|
850
|
+
state.unfocused = false;
|
|
851
|
+
const awayMs = Date.now() - state.awaySince;
|
|
852
|
+
if (awayMs > 30000 && state.awayNew > 0) {
|
|
853
|
+
state.statusText = c.dim(` Welcome back — ${state.awayNew} new line${state.awayNew === 1 ? '' : 's'} while away`);
|
|
854
|
+
draw();
|
|
855
|
+
setTimeout(() => { if (state.statusText?.includes('Welcome back')) { state.statusText = null; draw(); } }, 2600);
|
|
856
|
+
}
|
|
857
|
+
state.awayNew = 0;
|
|
858
|
+
} else if (!focused && !state.unfocused) {
|
|
859
|
+
state.unfocused = true; state.awaySince = Date.now(); state.awayNew = 0;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
function bellIfAway() { if (state.unfocused) { try { rawWrite('\x07'); } catch { /* headless */ } } }
|
|
863
|
+
|
|
864
|
+
/** Image chips (§3.5): clipboard pixels become a positional `[Image #N]`. */
|
|
865
|
+
function clipboardImageBytes() {
|
|
866
|
+
try {
|
|
867
|
+
if (process.platform === 'win32') {
|
|
868
|
+
const ps = spawnSync('powershell.exe', ['-NoProfile', '-Command',
|
|
869
|
+
'$i=Get-Clipboard -Format Image; if(!$i){exit 1}; $m=New-Object IO.MemoryStream; $i.Save($m,[Drawing.Imaging.ImageFormat]::Png); [Convert]::ToBase64String($m.ToArray())'],
|
|
870
|
+
{ encoding: 'utf8', timeout: 8000 });
|
|
871
|
+
if (ps.status === 0 && ps.stdout.trim()) return Buffer.from(ps.stdout.trim(), 'base64');
|
|
872
|
+
} else if (process.platform === 'darwin') {
|
|
873
|
+
const r = spawnSync('pngpaste', ['-'], { timeout: 8000 });
|
|
874
|
+
if (r.status === 0 && r.stdout?.length) return Buffer.from(r.stdout);
|
|
875
|
+
} else {
|
|
876
|
+
for (const [cmd, args] of [['wl-paste', ['--type', 'image/png']], ['xclip', ['-selection', 'clipboard', '-t', 'image/png', '-o']]]) {
|
|
877
|
+
const r = spawnSync(cmd, args, { timeout: 8000 });
|
|
878
|
+
if (r.status === 0 && r.stdout?.length) return Buffer.from(r.stdout);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
} catch { /* no image available */ }
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
async function pasteImageFromClipboard() {
|
|
885
|
+
const bytes = clipboardImageBytes();
|
|
886
|
+
if (!bytes?.length) {
|
|
887
|
+
state.statusText = c.dim(' No image in the clipboard');
|
|
888
|
+
draw(); setTimeout(() => { if (state.statusText?.includes('No image')) { state.statusText = null; draw(); } }, 1500);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
try {
|
|
892
|
+
const file = path.join(os.tmpdir(), `jaitus-clip-${Date.now()}.png`);
|
|
893
|
+
fs.writeFileSync(file, bytes);
|
|
894
|
+
const { imageBlock } = await import('./agent/tools.js');
|
|
895
|
+
const block = await imageBlock(file);
|
|
896
|
+
if (!block) throw new Error('unreadable');
|
|
897
|
+
const chip = `[Image #${state.attachments.length + 1}]`;
|
|
898
|
+
state.attachments.push({ chip, block });
|
|
899
|
+
insert(`${chip} `);
|
|
900
|
+
} catch {
|
|
901
|
+
state.statusText = c.dim(' Clipboard image unreadable (png/jpg/gif/webp, max ~3 MB)');
|
|
902
|
+
draw(); setTimeout(() => { state.statusText = null; draw(); }, 1800);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function isWordChar(ch) {
|
|
907
|
+
// iTerm2-style boundaries so a file path selects as one unit; URL scheme included.
|
|
908
|
+
return /[A-Za-z0-9_./\-:@+%]/.test(ch);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function wordAt(line, col) {
|
|
912
|
+
const chars = [...line];
|
|
913
|
+
let s = Math.max(0, Math.min(col, chars.length));
|
|
914
|
+
let e = s;
|
|
915
|
+
if (s < chars.length && !isWordChar(chars[s]) && /\s/.test(chars[s] || '')) {
|
|
916
|
+
while (s > 0 && /\s/.test(chars[s - 1])) s--;
|
|
917
|
+
while (e < chars.length && /\s/.test(chars[e])) e++;
|
|
918
|
+
return { s, e };
|
|
919
|
+
}
|
|
920
|
+
while (s > 0 && isWordChar(chars[s - 1])) s--;
|
|
921
|
+
while (e < chars.length && isWordChar(chars[e])) e++;
|
|
922
|
+
// Double-clicking a URL selects the whole URL including the scheme.
|
|
923
|
+
const joined = chars.join('');
|
|
924
|
+
const urlRe = /https?:\/\/[^\s]+/g;
|
|
925
|
+
let m;
|
|
926
|
+
while ((m = urlRe.exec(joined))) {
|
|
927
|
+
if (s >= m.index && e <= m.index + m[0].length) return { s: m.index, e: m.index + m[0].length };
|
|
928
|
+
}
|
|
929
|
+
return { s, e };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/** Map a screen row (1-based) to a transcript content index. */
|
|
933
|
+
function screenRowToContent(row1, w, h) {
|
|
934
|
+
const cw = contentWidth(w);
|
|
935
|
+
const bottom = bottomRows(cw);
|
|
936
|
+
const browsing = !state.panel && !state.viewingTranscript && state.transcriptOffset > 0;
|
|
937
|
+
const headLen = 2;
|
|
938
|
+
const space = Math.max(1, h - headLen - bottom.length - (browsing ? 1 : 0));
|
|
939
|
+
const content = transcriptContent(cw);
|
|
940
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
941
|
+
const start = Math.max(0, end - space);
|
|
942
|
+
const bodyFirst = headLen + (browsing ? 1 : 0) + 1; // 1-based first body row
|
|
943
|
+
const idx = start + (row1 - bodyFirst);
|
|
944
|
+
if (idx < start || idx >= end) return -1;
|
|
945
|
+
return idx;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function stickyPromptRow(w) {
|
|
949
|
+
const content = transcriptContent(w);
|
|
950
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
951
|
+
let prompt = '';
|
|
952
|
+
for (let i = Math.min(end, content.length) - 1; i >= 0; i--) {
|
|
953
|
+
const m = /^\s*›\s*(.*)$/.exec(String(content[i] ?? '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
954
|
+
if (m) { prompt = m[1].trim(); break; }
|
|
955
|
+
}
|
|
956
|
+
const fresh = state.newMessages ? `${state.newMessages} new · ` : '';
|
|
957
|
+
if (!prompt) return c.dim(` ↑ Browsing earlier messages · ${fresh}Jump to bottom: Ctrl+End / click here`);
|
|
958
|
+
const max = Math.max(20, w - fresh.length - 34);
|
|
959
|
+
const short = prompt.length > max ? `${prompt.slice(0, max - 1)}…` : prompt;
|
|
960
|
+
return c.dim(` ↑ ${short} · ${fresh}Jump to bottom: Ctrl+End / click here`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function panelRows(space, w) {
|
|
964
|
+
const lines = state.panel?.lines || [];
|
|
965
|
+
const end = Math.max(0, lines.length - (state.panel?.offset || 0));
|
|
966
|
+
return lines.slice(Math.max(0, end - space), end).flatMap((line) => cleanLine(line, w - 2));
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function vimTag() {
|
|
970
|
+
if (vimMode() !== 'vim' || state.viewingTranscript) return '';
|
|
971
|
+
if (state.vim === 'normal') return c.yellow(' -- NORMAL --');
|
|
972
|
+
if (state.vim === 'visual') return c.yellow(' -- VISUAL --');
|
|
973
|
+
return c.dim(' -- INSERT --');
|
|
974
|
+
}
|
|
975
|
+
// Braille spinner with a shimmer sweep across the label (§8.1): the base
|
|
976
|
+
// coral and its lighter twin trade places as the tick advances. Static ◆
|
|
977
|
+
// when animations are off (§8.3).
|
|
978
|
+
const SPIN_FRAMES = process.env.JAITUS_ASCII
|
|
979
|
+
? ['|', '/', '-', '\\']
|
|
980
|
+
: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
981
|
+
const BUSY_TIPS = [
|
|
982
|
+
'Esc interrupts',
|
|
983
|
+
'type + Enter queues',
|
|
984
|
+
'/diff reviews the changes',
|
|
985
|
+
'Ctrl+O opens the transcript',
|
|
986
|
+
'Shift+Tab changes modes',
|
|
987
|
+
'/research digs with sources',
|
|
988
|
+
'/test --fix repairs failures',
|
|
989
|
+
'/commit writes the message',
|
|
990
|
+
'/explain reads the code for you',
|
|
991
|
+
'/code-review --fix ships cleaner',
|
|
992
|
+
'/rewind un-breaks the timeline',
|
|
993
|
+
'/btw asks off the record',
|
|
994
|
+
'/branch tries the other way',
|
|
995
|
+
];
|
|
996
|
+
function shimmer(text, tick) {
|
|
997
|
+
const chars = [...text];
|
|
998
|
+
const half = Math.max(1, Math.ceil(chars.length / 2));
|
|
999
|
+
return chars.map((ch, i) => ((i + tick) % chars.length < half ? c.brand(ch) : c.shimmer(ch))).join('');
|
|
1000
|
+
}
|
|
1001
|
+
function statusLine(w = width()) {
|
|
1002
|
+
if (state.statusText !== null) return state.statusText;
|
|
1003
|
+
if (state.busy) {
|
|
1004
|
+
if (!state.busyAnim) return `${c.brand(' ◆ Working…')} ${c.dim('Esc interrupt · type and Enter to queue')}${vimTag()}`;
|
|
1005
|
+
const frame = SPIN_FRAMES[state.busyTick % SPIN_FRAMES.length];
|
|
1006
|
+
const tip = BUSY_TIPS[Math.floor(state.busyTick / 30) % BUSY_TIPS.length];
|
|
1007
|
+
return `${c.brand(` ${frame} `)}${shimmer('Working…', state.busyTick)} ${c.dim(`${tip} · type and Enter to queue`)}${vimTag()}`;
|
|
1008
|
+
}
|
|
1009
|
+
// opencode-style prompt footer: model and approval posture lead,
|
|
1010
|
+
// key hints follow.
|
|
1011
|
+
return idleFooter(w);
|
|
1012
|
+
}
|
|
1013
|
+
function idleFooter(w) {
|
|
1014
|
+
// opencode bottom-row shape: directory leads muted on the left, key
|
|
1015
|
+
// hints close on the right (bright key, muted purpose). Model, approval
|
|
1016
|
+
// and version live in the composer meta row instead. Bypass is never
|
|
1017
|
+
// quiet (§11) — its badge replaces the hints.
|
|
1018
|
+
const project = path.basename(state.meta.cwd || '') || state.meta.cwd || '.';
|
|
1019
|
+
const bypass = state.meta.mode === 'bypassPermissions';
|
|
1020
|
+
const left = ` ${c.dim(project)}`;
|
|
1021
|
+
const sep = c.dim(' · ');
|
|
1022
|
+
const hints = `${glyph('⇧↹', 'Shift+Tab')} ${c.dim('modes')}${sep}/ ${c.dim('commands')}${sep}@ ${c.dim('files')}`;
|
|
1023
|
+
const right = bypass ? c.red(c.bold('● BYPASS — no checks')) : hints;
|
|
1024
|
+
if (visibleWidth(left) + visibleWidth(right) + 3 > w) {
|
|
1025
|
+
return `${right}${vimTag()}`;
|
|
1026
|
+
}
|
|
1027
|
+
const pad = Math.max(1, w - visibleWidth(left) - visibleWidth(right));
|
|
1028
|
+
return `${left}${' '.repeat(pad)}${right}${vimTag()}`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** opencode home: on a brand-new session the composer is a narrow
|
|
1032
|
+
* centered column, not full width. Active only while state.home is set
|
|
1033
|
+
* (fresh open until first submit) and nothing else owns the bottom.
|
|
1034
|
+
* Returns {cw, pad} or null for the session layout. */
|
|
1035
|
+
function homeMetrics(w) {
|
|
1036
|
+
if (!state.home || state.dialog || state.panel || state.viewingTranscript
|
|
1037
|
+
|| state.historySearch || state.search || state.busy) return null;
|
|
1038
|
+
const cw = Math.min(w, 90);
|
|
1039
|
+
return { cw, pad: Math.max(0, Math.floor((w - cw) / 2)) };
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** The fixed lower block for the current mode — the frame's bottom rows. */
|
|
1043
|
+
function bottomRows(w) {
|
|
1044
|
+
if (state.panel) {
|
|
1045
|
+
return [
|
|
1046
|
+
c.grey(` ${'─'.repeat(Math.max(8, w - 4))}`),
|
|
1047
|
+
` ${c.bold(state.panel.title)}`,
|
|
1048
|
+
c.dim(' ↑↓ scroll PgUp/PgDn page Ctrl+Home/End ends Esc close'),
|
|
1049
|
+
];
|
|
1050
|
+
}
|
|
1051
|
+
if (state.commandPalette) {
|
|
1052
|
+
return [c.dim(` ${glyph('⌘', 'Ctrl')}K command palette · ${paletteMatches().length} commands · Esc close`)];
|
|
1053
|
+
}
|
|
1054
|
+
if (state.viewingTranscript) {
|
|
1055
|
+
if (state.search) {
|
|
1056
|
+
return [
|
|
1057
|
+
c.grey(` ${'─'.repeat(Math.max(8, w - 4))}`),
|
|
1058
|
+
` ${c.brand('/')} ${state.search.query}`,
|
|
1059
|
+
c.dim(' Enter keep search Esc cancel ↑↓ navigate'),
|
|
1060
|
+
];
|
|
1061
|
+
}
|
|
1062
|
+
return [c.grey(` ${'─'.repeat(Math.max(8, w - 4))}`), c.dim(` Transcript · / search${state.lastSearch ? ' · n/N match' : ''} · j/k line · s select/y copy · PgUp/PgDn half · g/G ends · {/} prompt · [ scrollback · v editor · Esc return`)];
|
|
1063
|
+
}
|
|
1064
|
+
if (state.historySearch) return historySearchRows(state.historySearch, w);
|
|
1065
|
+
if (state.dialog) return dialogRows(state.dialog, w);
|
|
1066
|
+
const hm = homeMetrics(w);
|
|
1067
|
+
const cw = hm ? hm.cw : w;
|
|
1068
|
+
const cr = composerRows({ input: state.input, menu: state.menu, menuIndex: state.menuIndex, status: statusLine(cw), queued: state.queued, width: cw, mode: state.meta.mode, model: state.meta.model, version: state.meta.version });
|
|
1069
|
+
// Scripted status line (§13) renders verbatim just above the hint footer.
|
|
1070
|
+
if (state.customStatus) cr.splice(cr.length - 1, 0, state.customStatus);
|
|
1071
|
+
if (!hm) return cr;
|
|
1072
|
+
// Home column: every composer row rides centered, footer included.
|
|
1073
|
+
return cr.map((r) => ' '.repeat(hm.pad) + r);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* THE single source of truth. Builds every row of the frame and the cursor cell.
|
|
1078
|
+
* The cursor is an index inside the same `rows` array that gets painted, so the
|
|
1079
|
+
* two can never disagree.
|
|
1080
|
+
*/
|
|
1081
|
+
function layout() {
|
|
1082
|
+
const w = width(); const h = height();
|
|
1083
|
+
const metrics = columnMetrics(w);
|
|
1084
|
+
const cw = metrics.center;
|
|
1085
|
+
const bottom = bottomRows(cw);
|
|
1086
|
+
const browsing = !state.panel && !state.viewingTranscript && state.transcriptOffset > 0;
|
|
1087
|
+
// Sticky section header (§4.2): the most recent prompt scrolled out of view,
|
|
1088
|
+
// clickable to jump back to the bottom. Falls back to the generic banner
|
|
1089
|
+
// when no prompt is above the viewport.
|
|
1090
|
+
const browsingRow = browsing ? stickyPromptRow(cw) : null;
|
|
1091
|
+
|
|
1092
|
+
const head = headerRows(w);
|
|
1093
|
+
const space = Math.max(1, h - head.length - bottom.length - (browsingRow ? 1 : 0));
|
|
1094
|
+
const intro = metrics.left && !state.panel && !state.commandPalette && !state.viewingTranscript
|
|
1095
|
+
? sessionIntroRows(cw)
|
|
1096
|
+
: [];
|
|
1097
|
+
let body = state.panel
|
|
1098
|
+
? panelRows(space, cw)
|
|
1099
|
+
: state.commandPalette
|
|
1100
|
+
? paletteRows(space, cw)
|
|
1101
|
+
: [...intro, ...transcriptRows(Math.max(1, space - intro.length), cw)];
|
|
1102
|
+
// Self-healing viewport: an empty body while content exists is never a
|
|
1103
|
+
// valid view (it used to happen scrolling to the exact top). Snap back to
|
|
1104
|
+
// live rather than show a black hole.
|
|
1105
|
+
if (!state.panel && !state.commandPalette && !body.length && transcriptContent(cw).length > 0) {
|
|
1106
|
+
state.transcriptOffset = 0; state.newMessages = 0;
|
|
1107
|
+
body = transcriptRows(space, cw);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const middle = [
|
|
1111
|
+
...(browsingRow ? [browsingRow] : []),
|
|
1112
|
+
...body,
|
|
1113
|
+
...Array(Math.max(0, space - body.length - (browsingRow ? 1 : 0))).fill(''),
|
|
1114
|
+
...bottom,
|
|
1115
|
+
].slice(0, h - head.length);
|
|
1116
|
+
const railLeft = leftRailRows(middle.length);
|
|
1117
|
+
const railRight = inspectorRows(middle.length);
|
|
1118
|
+
const rows = [
|
|
1119
|
+
...head,
|
|
1120
|
+
...middle.map((line, index) => frameRow(railLeft[index] || '', line, railRight[index] || '', metrics)),
|
|
1121
|
+
].slice(0, h);
|
|
1122
|
+
|
|
1123
|
+
// Cursor placement — derived from row indices inside this same array.
|
|
1124
|
+
let cursor = null;
|
|
1125
|
+
if (state.panel) {
|
|
1126
|
+
cursor = null; // hidden while a modal owns the screen
|
|
1127
|
+
} else if (state.commandPalette) {
|
|
1128
|
+
cursor = { row: Math.min(h, head.length + 2), col: Math.min(w, metrics.left + (metrics.left ? 1 : 0) + 5 + String(state.commandPalette.query || '').length) };
|
|
1129
|
+
} else if (state.dialog) {
|
|
1130
|
+
// Visible only while writing the decision comment.
|
|
1131
|
+
if (state.dialog.editing) {
|
|
1132
|
+
const off = dialogCommentOffset(state.dialog, cw);
|
|
1133
|
+
if (off >= 0) cursor = { row: rows.length - bottom.length + off + 1, col: Math.min(w, metrics.left + (metrics.left ? 1 : 0) + 5 + [...(state.dialog.comment || '')].length) };
|
|
1134
|
+
else cursor = null;
|
|
1135
|
+
} else cursor = null;
|
|
1136
|
+
} else if (state.historySearch) {
|
|
1137
|
+
// Query lives on the second bottom row, after ` ⌕ Reverse history `.
|
|
1138
|
+
cursor = { row: rows.length - bottom.length + 2, col: Math.min(w, 22 + state.historySearch.query.length) };
|
|
1139
|
+
} else if (state.viewingTranscript && state.search) {
|
|
1140
|
+
// Query lives on the second bottom row, after ` / `.
|
|
1141
|
+
cursor = { row: rows.length - bottom.length + 2, col: Math.min(w, 5 + state.search.query.length) };
|
|
1142
|
+
} else if (!state.viewingTranscript) {
|
|
1143
|
+
const hm = homeMetrics(cw);
|
|
1144
|
+
const composerWidth = hm ? hm.cw : cw;
|
|
1145
|
+
const pad = hm ? hm.pad : 0;
|
|
1146
|
+
const promptCount = promptRows(state.input, composerWidth).length;
|
|
1147
|
+
// 0-based: the three rows after the prompt are meta, status and the box edge.
|
|
1148
|
+
const firstPrompt = rows.length - 3 - promptCount;
|
|
1149
|
+
const cell = promptCursor(state.input, state.cursor, composerWidth);
|
|
1150
|
+
cursor = {
|
|
1151
|
+
row: Math.min(h, Math.max(1, firstPrompt + cell.row + 1)),
|
|
1152
|
+
col: Math.min(w, Math.max(1, cell.column + pad + (metrics.left ? metrics.left + 1 : 0))),
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
return { rows, cursor, w, h };
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Hard cap any row below the terminal width (invariant 1), ANSI-aware. */
|
|
1159
|
+
function safeRow(line, w) {
|
|
1160
|
+
return visibleWidth(line) > w ? cleanLine(line, w - 1)[0] : line;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function draw() {
|
|
1164
|
+
if (!active) return;
|
|
1165
|
+
const { rows, cursor, w, h } = layout();
|
|
1166
|
+
// Synchronized output (§8.4): the frame lands atomically, no line-by-line
|
|
1167
|
+
// flash. Terminals without support ignore the wrapper. (Every frame here
|
|
1168
|
+
// is already a full repaint, so ConPTY hosts stay correct.)
|
|
1169
|
+
const parts = ['\x1b[?2026h\x1b[?7l\x1b[?25l'];
|
|
1170
|
+
for (let i = 0; i < h; i++) {
|
|
1171
|
+
parts.push(`\x1b[${i + 1};1H\x1b[0K${safeRow(rows[i] ?? '', w)}`);
|
|
1172
|
+
}
|
|
1173
|
+
if (cursor) parts.push(`\x1b[${cursor.row};${cursor.col}H\x1b[?25h`);
|
|
1174
|
+
parts.push('\x1b[?2026l');
|
|
1175
|
+
rawWrite(parts.join(''));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function noteNewMessage() { if (state.transcriptOffset > 0) state.newMessages += 1; }
|
|
1179
|
+
function print(text, kind = 'other') {
|
|
1180
|
+
if (!active) { rawWrite(`${text}\n`); return; }
|
|
1181
|
+
commitLive(); noteNewMessage(); addTranscript(text, kind);
|
|
1182
|
+
if (state.unfocused) state.awayNew += cleanLine(text, width() - 2).length;
|
|
1183
|
+
draw();
|
|
1184
|
+
}
|
|
1185
|
+
function printStream(text) {
|
|
1186
|
+
if (!active) { rawWrite(String(text)); return; }
|
|
1187
|
+
if (state.transcriptOffset > 0 && !state.liveUnseen) { state.newMessages += 1; state.liveUnseen = true; }
|
|
1188
|
+
if (state.unfocused) state.awayNew += 1;
|
|
1189
|
+
if (!state.liveText) state.liveKind = 'assistant';
|
|
1190
|
+
state.liveText += String(text); draw();
|
|
1191
|
+
}
|
|
1192
|
+
function flushStream() { commitLive(); draw(); }
|
|
1193
|
+
function insert(value) {
|
|
1194
|
+
snapshotUndo('type');
|
|
1195
|
+
// `.` repeatable change: coalesce one insert run into a single change.
|
|
1196
|
+
if (state.vimInsertActive && state.lastChange?.t === 'i' && state.lastChange.session === state.vimSession) {
|
|
1197
|
+
state.lastChange.text += value;
|
|
1198
|
+
} else {
|
|
1199
|
+
state.lastChange = { t: 'i', text: value, session: state.vimInsertActive ? state.vimSession : -1 };
|
|
1200
|
+
}
|
|
1201
|
+
state.input = state.input.slice(0, state.cursor) + value + state.input.slice(state.cursor); state.cursor += value.length; updateMenu(); draw();
|
|
1202
|
+
}
|
|
1203
|
+
/** Begin an insert run (every i/a/o/... entry point calls this). */
|
|
1204
|
+
function beginInsert() { state.vimSession += 1; state.vimInsertActive = true; state.vim = 'insert'; }
|
|
1205
|
+
function endInsert() { state.vimInsertActive = false; }
|
|
1206
|
+
/** Push the pre-edit buffer for Ctrl+_ undo (cap 50, coalesce typing runs). */
|
|
1207
|
+
function snapshotUndo(kind = 'edit') {
|
|
1208
|
+
const top = state.undoStack[state.undoStack.length - 1];
|
|
1209
|
+
if (top && top.input === state.input && top.cursor === state.cursor) return;
|
|
1210
|
+
// A typing run is one undo step: skip if the top is a recent typing snapshot.
|
|
1211
|
+
if (kind === 'type' && top?.kind === 'type' && Date.now() - (top.at || 0) < 1500) return;
|
|
1212
|
+
state.undoStack.push({ input: state.input, cursor: state.cursor, kind, at: Date.now() });
|
|
1213
|
+
if (state.undoStack.length > 50) state.undoStack.shift();
|
|
1214
|
+
}
|
|
1215
|
+
function recallUndo() {
|
|
1216
|
+
const prev = state.undoStack.pop();
|
|
1217
|
+
if (!prev) return;
|
|
1218
|
+
state.redoStack.push({ input: state.input, cursor: state.cursor });
|
|
1219
|
+
state.input = prev.input; state.cursor = Math.min(prev.cursor, prev.input.length);
|
|
1220
|
+
updateMenu(); draw();
|
|
1221
|
+
}
|
|
1222
|
+
function recallRedo() {
|
|
1223
|
+
const next = state.redoStack.pop();
|
|
1224
|
+
if (!next) return;
|
|
1225
|
+
state.undoStack.push({ input: state.input, cursor: state.cursor, kind: 'edit', at: Date.now() });
|
|
1226
|
+
state.input = next.input; state.cursor = Math.min(next.cursor, next.input.length);
|
|
1227
|
+
updateMenu(); draw();
|
|
1228
|
+
}
|
|
1229
|
+
function vimMode() {
|
|
1230
|
+
try {
|
|
1231
|
+
const m = process.env.JAITUS_EDITOR || loadSettings().editorMode;
|
|
1232
|
+
return m === 'vim' ? 'vim' : 'emacs';
|
|
1233
|
+
} catch { return 'emacs'; }
|
|
1234
|
+
}
|
|
1235
|
+
/** Kill-ring (§3.2): every Ctrl+K/U/W deletion is paste-back-able via Ctrl+Y. */
|
|
1236
|
+
function kill(text, dir) {
|
|
1237
|
+
if (!text) return;
|
|
1238
|
+
state.lastKill = text;
|
|
1239
|
+
if (dir === 'back') state.killRing.unshift(text);
|
|
1240
|
+
else state.killRing.push(text);
|
|
1241
|
+
if (state.killRing.length > 20) state.killRing.pop();
|
|
1242
|
+
state.killIndex = -1;
|
|
1243
|
+
}
|
|
1244
|
+
function yank() {
|
|
1245
|
+
if (!state.killRing.length) return;
|
|
1246
|
+
state.killIndex = (state.killIndex + 1) % state.killRing.length;
|
|
1247
|
+
insert(state.killRing[state.killIndex]);
|
|
1248
|
+
}
|
|
1249
|
+
// Word = run of letters/digits; _ . / and friends are boundaries (§3.2).
|
|
1250
|
+
const isWordCharEdit = (ch) => /[A-Za-z0-9]/.test(ch || '');
|
|
1251
|
+
/** Visual rows of the draft with raw-input offsets (wrapping-aware). */
|
|
1252
|
+
function visualRows(usable = Math.max(12, width() - PROMPT_USABLE_SLACK)) {
|
|
1253
|
+
const rows = [];
|
|
1254
|
+
let raw = 0;
|
|
1255
|
+
for (const logical of state.input.split('\n')) {
|
|
1256
|
+
const chars = [...logical];
|
|
1257
|
+
if (!chars.length) { rows.push({ start: raw, text: '' }); }
|
|
1258
|
+
else {
|
|
1259
|
+
let vRow = ''; let vWidth = 0; let rowStart = raw;
|
|
1260
|
+
for (const ch of chars) {
|
|
1261
|
+
const cw = visibleWidth(ch);
|
|
1262
|
+
if (vWidth + cw > usable && vRow) { rows.push({ start: rowStart, text: vRow }); rowStart += [...vRow].length; vRow = ''; vWidth = 0; }
|
|
1263
|
+
vRow += ch; vWidth += cw;
|
|
1264
|
+
}
|
|
1265
|
+
rows.push({ start: rowStart, text: vRow });
|
|
1266
|
+
}
|
|
1267
|
+
raw += chars.length + 1; // +1 for the \n itself
|
|
1268
|
+
}
|
|
1269
|
+
return rows;
|
|
1270
|
+
}
|
|
1271
|
+
function lineStartAt(pos) {
|
|
1272
|
+
const i = state.input.lastIndexOf('\n', Math.max(0, pos - 1));
|
|
1273
|
+
return i === -1 ? 0 : i + 1;
|
|
1274
|
+
}
|
|
1275
|
+
function lineEndAt(pos) {
|
|
1276
|
+
const i = state.input.indexOf('\n', Math.max(0, pos));
|
|
1277
|
+
return i === -1 ? state.input.length : i;
|
|
1278
|
+
}
|
|
1279
|
+
function wordBack(from) {
|
|
1280
|
+
let i = Math.max(0, Math.min(from, state.input.length));
|
|
1281
|
+
while (i > 0 && !isWordCharEdit(state.input[i - 1])) i--;
|
|
1282
|
+
while (i > 0 && isWordCharEdit(state.input[i - 1])) i--;
|
|
1283
|
+
return i;
|
|
1284
|
+
}
|
|
1285
|
+
function wordForward(from) {
|
|
1286
|
+
// `w`: start of the next word (from mid-word, that means past this one).
|
|
1287
|
+
const s = state.input;
|
|
1288
|
+
let i = Math.max(0, Math.min(from, s.length));
|
|
1289
|
+
if (isWordCharEdit(s[i])) while (i < s.length && isWordCharEdit(s[i])) i++;
|
|
1290
|
+
while (i < s.length && !isWordCharEdit(s[i])) i++;
|
|
1291
|
+
return i;
|
|
1292
|
+
}
|
|
1293
|
+
function wordEnd(from) {
|
|
1294
|
+
const s = state.input;
|
|
1295
|
+
const len = s.length;
|
|
1296
|
+
let i = Math.max(0, Math.min(from, len));
|
|
1297
|
+
if (i >= len) return len;
|
|
1298
|
+
// On the last char of a word (or past the end): head for the next word.
|
|
1299
|
+
if (isWordCharEdit(s[i]) && (i + 1 >= len || !isWordCharEdit(s[i + 1]))) i++;
|
|
1300
|
+
while (i < len && !isWordCharEdit(s[i])) i++;
|
|
1301
|
+
while (i + 1 < len && isWordCharEdit(s[i + 1])) i++;
|
|
1302
|
+
return Math.min(i, len);
|
|
1303
|
+
}
|
|
1304
|
+
/** Paste chips (§3.4): big pastes collapse, full text cached for submit. */
|
|
1305
|
+
function addPaste(text) {
|
|
1306
|
+
const id = ++state.pasteSeq;
|
|
1307
|
+
state.pastes.set(id, text);
|
|
1308
|
+
const lines = text.split('\n').length;
|
|
1309
|
+
return `[Pasted text #${id} +${lines} line${lines === 1 ? '' : 's'}]`;
|
|
1310
|
+
}
|
|
1311
|
+
function expandPastes(text) {
|
|
1312
|
+
return String(text).replace(/\[Pasted text #(\d+) \+\d+ lines?\]/g, (m, id) => state.pastes.get(Number(id)) ?? m);
|
|
1313
|
+
}
|
|
1314
|
+
// Image chips (§3.5): only chips still present in the submitted line attach.
|
|
1315
|
+
function takeAttachments(line = '') {
|
|
1316
|
+
const kept = state.attachments.filter((a) => line.includes(a.chip));
|
|
1317
|
+
state.attachments = [];
|
|
1318
|
+
return kept.map((a) => a.block);
|
|
1319
|
+
}
|
|
1320
|
+
function submit() {
|
|
1321
|
+
// Paste chips expand to their full cached text; history keeps the full
|
|
1322
|
+
// paste so recalling it still sends everything (§3.4).
|
|
1323
|
+
const line = expandPastes(state.input);
|
|
1324
|
+
state.input = ''; state.cursor = 0; state.menu = []; state.menuIndex = -1;
|
|
1325
|
+
state.vimOp = null; state.vimNum = ''; state.vimReplace = false; state.vimPendingG = false; state.vimPendingObj = null; endInsert();
|
|
1326
|
+
// Auto-scroll off stays where you left it; otherwise show the new exchange.
|
|
1327
|
+
let autoScroll = true;
|
|
1328
|
+
try { autoScroll = loadSettings().autoScroll !== false; } catch { /* default on */ }
|
|
1329
|
+
if (autoScroll) { state.transcriptOffset = 0; state.newMessages = 0; }
|
|
1330
|
+
if (line.trim()) { state.history.push(line); state.historyIndex = -1; state.draft = null; }
|
|
1331
|
+
draw(); if (state.onLine) Promise.resolve(state.onLine(line)).catch((error) => print(c.red(` ${error?.message || error}`)));
|
|
1332
|
+
}
|
|
1333
|
+
function acceptMenu() {
|
|
1334
|
+
const item = state.menu[state.menuIndex]; if (!item) return false;
|
|
1335
|
+
snapshotUndo();
|
|
1336
|
+
if (item.kind === 'command') { const slash = state.input.lastIndexOf('/'); state.input = `${state.input.slice(0, slash)}${item.value} `; }
|
|
1337
|
+
else if (item.kind === 'emoji') { const m = /:([A-Za-z0-9_+-]*)$/.exec(state.input); state.input = m ? `${state.input.slice(0, m.index)}${item.value} ` : state.input + item.value; }
|
|
1338
|
+
else if (item.kind === 'history') { state.input = item.value; }
|
|
1339
|
+
else { const at = state.input.lastIndexOf('@'); state.input = `${state.input.slice(0, at)}@${item.value} `; }
|
|
1340
|
+
state.cursor = state.input.length; updateMenu(); draw(); return true;
|
|
1341
|
+
}
|
|
1342
|
+
function historyMove(direction) {
|
|
1343
|
+
if (!state.history.length) return;
|
|
1344
|
+
if (direction > 0) {
|
|
1345
|
+
if (state.historyIndex < 0) return;
|
|
1346
|
+
state.historyIndex += 1;
|
|
1347
|
+
state.input = state.historyIndex >= state.history.length ? (state.historyIndex = -1, state.draft || '') : state.history[state.historyIndex];
|
|
1348
|
+
} else {
|
|
1349
|
+
if (state.historyIndex < 0) state.draft = state.input;
|
|
1350
|
+
state.historyIndex = Math.max(0, state.historyIndex < 0 ? state.history.length - 1 : state.historyIndex - 1);
|
|
1351
|
+
state.input = state.history[state.historyIndex];
|
|
1352
|
+
}
|
|
1353
|
+
state.cursor = state.input.length; updateMenu(); draw();
|
|
1354
|
+
}
|
|
1355
|
+
function refreshHistorySearch() {
|
|
1356
|
+
const query = state.historySearch.query.toLowerCase();
|
|
1357
|
+
state.historySearch.matches = state.history.filter((entry) => entry.toLowerCase().includes(query)).reverse();
|
|
1358
|
+
state.historySearch.index = state.historySearch.matches.length ? Math.min(state.historySearch.index, state.historySearch.matches.length - 1) : -1;
|
|
1359
|
+
draw();
|
|
1360
|
+
}
|
|
1361
|
+
function beginHistorySearch() {
|
|
1362
|
+
state.historySearch = { query: '', matches: [...state.history].reverse().slice(0, 5), index: 0 };
|
|
1363
|
+
draw();
|
|
1364
|
+
}
|
|
1365
|
+
function finishHistorySearch(accept = false) {
|
|
1366
|
+
if (accept && state.historySearch?.matches[state.historySearch.index]) {
|
|
1367
|
+
state.input = state.historySearch.matches[state.historySearch.index]; state.cursor = state.input.length; updateMenu();
|
|
1368
|
+
}
|
|
1369
|
+
state.historySearch = null; draw();
|
|
1370
|
+
}
|
|
1371
|
+
// Overscroll fix: the viewport window is [end-space, end) with
|
|
1372
|
+
// end = len - offset. Clamping to len-1 still lands on slivers — one line
|
|
1373
|
+
// plus a screen of blanks, which reads as broken. Instead the offset may
|
|
1374
|
+
// never exceed len - space, so a scrolled view is always a FULL screen of
|
|
1375
|
+
// content; when the history is shorter than the viewport there is simply
|
|
1376
|
+
// nothing to scroll (offset stays 0).
|
|
1377
|
+
function maxScrollOffset() {
|
|
1378
|
+
const w = contentWidth(); const h = height();
|
|
1379
|
+
const space = Math.max(1, h - 2 - bottomRows(w).length - 1);
|
|
1380
|
+
return Math.max(0, transcriptContent(w).length - space);
|
|
1381
|
+
}
|
|
1382
|
+
function clampOffset(offset) {
|
|
1383
|
+
return Math.max(0, Math.min(offset, maxScrollOffset()));
|
|
1384
|
+
}
|
|
1385
|
+
function scrollBy(lines) {
|
|
1386
|
+
// Wheel speed multiplier (JAITUS_SCROLL_SPEED, or scrollSpeed in settings).
|
|
1387
|
+
const scaled = Math.round(lines * scrollSpeed()) || (lines > 0 ? 1 : -1);
|
|
1388
|
+
state.transcriptOffset = clampOffset(state.transcriptOffset + scaled);
|
|
1389
|
+
if (state.transcriptOffset === 0) state.newMessages = 0;
|
|
1390
|
+
draw();
|
|
1391
|
+
}
|
|
1392
|
+
function scrollTo(position) {
|
|
1393
|
+
state.transcriptOffset = position === 'top' ? clampOffset(transcriptContent(width()).length) : 0;
|
|
1394
|
+
if (state.transcriptOffset === 0) state.newMessages = 0;
|
|
1395
|
+
draw();
|
|
1396
|
+
}
|
|
1397
|
+
function jumpToPrompt(direction) {
|
|
1398
|
+
// { / } jump to previous / next user prompt, vim-paragraph style.
|
|
1399
|
+
const content = transcriptContent(width());
|
|
1400
|
+
const marks = [];
|
|
1401
|
+
content.forEach((line, i) => { if (/^\s*›/.test(String(line ?? '').replace(/\x1b\[[0-9;]*m/g, ''))) marks.push(i); });
|
|
1402
|
+
if (!marks.length) return;
|
|
1403
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
1404
|
+
const cur = end - 1;
|
|
1405
|
+
const target = direction < 0 ? [...marks].reverse().find((m) => m < cur - 1) : marks.find((m) => m > cur + 1);
|
|
1406
|
+
if (target === undefined) return;
|
|
1407
|
+
state.transcriptOffset = clampOffset(content.length - target - 1);
|
|
1408
|
+
draw();
|
|
1409
|
+
}
|
|
1410
|
+
function dumpToScrollback() {
|
|
1411
|
+
// '[': write full conversation to native scrollback so Cmd+F,
|
|
1412
|
+
// tmux copy mode and native selection can search it. Exit alt screen,
|
|
1413
|
+
// dump expanded, return to fullscreen on Esc/q.
|
|
1414
|
+
const lines = transcriptContent(width()).map((l) => String(l ?? '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
1415
|
+
const detail = state.activity.flatMap((a) => [`$ ${a.label} [${a.status}]`, ...(a.detail ? String(a.detail).split('\n') : [])]);
|
|
1416
|
+
const body = [...lines, '', ...detail].join('\n');
|
|
1417
|
+
rawWrite('\x1b[?1049l');
|
|
1418
|
+
try { fs.writeSync(1, `\n${body}\n\n[Fullscreen paused — native scrollback now holds the conversation. Press q/Esc then Enter to return: ]\n`); } catch { /* stdout gone */ }
|
|
1419
|
+
}
|
|
1420
|
+
function openInEditor() {
|
|
1421
|
+
// 'v': conversation to a temp file in $VISUAL/$EDITOR.
|
|
1422
|
+
const editor = process.env.VISUAL || process.env.EDITOR || (process.platform === 'win32' ? 'notepad' : 'vi');
|
|
1423
|
+
const file = path.join(os.tmpdir(), `jaitus-conversation-${Date.now()}.md`);
|
|
1424
|
+
try {
|
|
1425
|
+
const lines = transcriptContent(width()).map((l) => String(l ?? '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
1426
|
+
fs.writeFileSync(file, `# Jaitus conversation — ${new Date().toISOString()}\n\n${lines.join('\n')}\n`);
|
|
1427
|
+
rawWrite('\x1b[?1049l');
|
|
1428
|
+
// timeout guards headless/test runs where the editor would wait forever.
|
|
1429
|
+
const res = spawnSync(editor, [file], { stdio: 'inherit', timeout: 120000 });
|
|
1430
|
+
rawWrite('\x1b[?1049h\x1b[2J\x1b[H');
|
|
1431
|
+
draw();
|
|
1432
|
+
return res.status ?? 0;
|
|
1433
|
+
} catch (e) {
|
|
1434
|
+
try { rawWrite('\x1b[?1049h\x1b[2J\x1b[H'); } catch { /* restore */ }
|
|
1435
|
+
state.statusText = c.dim(` Could not open editor (${e.message})`);
|
|
1436
|
+
draw();
|
|
1437
|
+
setTimeout(() => { state.statusText = null; draw(); }, 1800);
|
|
1438
|
+
return 1;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
function clearSelectionUnlessKeeps(key, name, str) {
|
|
1442
|
+
// Esc / PgUp / PgDn / Ctrl+Home/End / modified nav keep selection;
|
|
1443
|
+
// any other key (plain arrows, Enter, typed chars) clears it.
|
|
1444
|
+
if (!state.selection) return;
|
|
1445
|
+
// Transcript-mode navigation and selection keys never clear the selection.
|
|
1446
|
+
if (state.viewingTranscript && !state.search) {
|
|
1447
|
+
if (['up', 'down', 'pageup', 'pagedown', 'home', 'end', 'escape'].includes(name)) return;
|
|
1448
|
+
if (['/', 'n', 'N', 'j', 'k', 'g', 'G', '{', '}', '[', 'v', 's', 'y', 'c', 'b', ' '].includes(str)) return;
|
|
1449
|
+
if (key.ctrl && ['u', 'd', 'b', 'f', 'o'].includes(name)) return;
|
|
1450
|
+
}
|
|
1451
|
+
const keeps = name === 'escape'
|
|
1452
|
+
|| name === 'pageup' || name === 'pagedown'
|
|
1453
|
+
|| (key.ctrl && (name === 'home' || name === 'end'))
|
|
1454
|
+
|| ((key.shift || key.alt || key.meta || key.ctrl) && ['up', 'down', 'left', 'right', 'home', 'end'].includes(name));
|
|
1455
|
+
if (!keeps) { state.selection = null; state._kbSelect = false; }
|
|
1456
|
+
}
|
|
1457
|
+
function panelScrollBy(lines) {
|
|
1458
|
+
if (!state.panel) return;
|
|
1459
|
+
const len = state.panel.lines.length;
|
|
1460
|
+
const space = Math.max(1, height() - 2 - 3);
|
|
1461
|
+
state.panel.offset = Math.max(0, Math.min(Math.max(0, len - space), state.panel.offset + lines));
|
|
1462
|
+
draw();
|
|
1463
|
+
}
|
|
1464
|
+
function closePanel() { const panel = state.panel; state.panel = null; draw(); panel?.resolve?.(); }
|
|
1465
|
+
function searchMatches(query) {
|
|
1466
|
+
const needle = query.toLowerCase();
|
|
1467
|
+
return state.transcript.reduce((hits, entry, index) => {
|
|
1468
|
+
if (needle && entry.text.toLowerCase().includes(needle)) hits.push(index);
|
|
1469
|
+
return hits;
|
|
1470
|
+
}, []);
|
|
1471
|
+
}
|
|
1472
|
+
function focusSearch(search, direction = 0) {
|
|
1473
|
+
search.matches = searchMatches(search.query);
|
|
1474
|
+
if (!search.matches.length) { search.index = -1; draw(); return; }
|
|
1475
|
+
if (direction) search.index = (search.index + direction + search.matches.length) % search.matches.length;
|
|
1476
|
+
else search.index = 0;
|
|
1477
|
+
state.transcriptOffset = clampOffset(state.transcript.length - search.matches[search.index] - 1);
|
|
1478
|
+
draw();
|
|
1479
|
+
}
|
|
1480
|
+
function beginSearch() {
|
|
1481
|
+
state.search = { query: '', matches: [], index: -1, restoreOffset: state.transcriptOffset };
|
|
1482
|
+
draw();
|
|
1483
|
+
}
|
|
1484
|
+
function finishSearch(cancel = false) {
|
|
1485
|
+
if (cancel) state.transcriptOffset = state.search?.restoreOffset || 0;
|
|
1486
|
+
else state.lastSearch = state.search?.query || state.lastSearch;
|
|
1487
|
+
state.search = null; draw();
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Vim modal editing (§3.2, /vim): insert/normal/visual with motions (h j k l
|
|
1491
|
+
* w e b 0 $ ^ g g G), operators (d x D C s S y p), counts, text object iw/aw,
|
|
1492
|
+
* and r{eplace}. Returns true when the key was consumed. Insert mode falls
|
|
1493
|
+
* through to the normal readline handling below.
|
|
1494
|
+
*/
|
|
1495
|
+
function handleVim(str, key = {}) {
|
|
1496
|
+
if (vimMode() !== 'vim') return false;
|
|
1497
|
+
const name = key.name;
|
|
1498
|
+
if (state.vim === 'insert') {
|
|
1499
|
+
if (name === 'escape') {
|
|
1500
|
+
state.vim = 'normal'; state.vimOp = null; state.vimNum = ''; state.vimReplace = false;
|
|
1501
|
+
state.vimEx = null; state.vimFindTyping = false;
|
|
1502
|
+
endInsert();
|
|
1503
|
+
state.cursor = Math.max(0, state.cursor - 1);
|
|
1504
|
+
updateMenu(); draw();
|
|
1505
|
+
return true;
|
|
1506
|
+
}
|
|
1507
|
+
return false;
|
|
1508
|
+
}
|
|
1509
|
+
// Ex line (`:q`) and draft find (`/`) capture typing while open.
|
|
1510
|
+
if (state.vimEx !== null) {
|
|
1511
|
+
if (name === 'escape') { state.vimEx = null; state.statusText = null; draw(); return true; }
|
|
1512
|
+
if (name === 'backspace') { state.vimEx = state.vimEx.slice(0, -1); state.statusText = c.dim(` :${state.vimEx}▏`); draw(); return true; }
|
|
1513
|
+
if (name === 'return' || name === 'enter') {
|
|
1514
|
+
const cmd = state.vimEx.trim(); state.vimEx = null;
|
|
1515
|
+
if (cmd === 'q' || cmd === 'q!') { state.shouldExit = true; return true; }
|
|
1516
|
+
if (cmd === 'h' || cmd === 'help') { print(c.dim(' Vim: h j k l w e b · 0 $ ^ gg G · d x D C S s · y p P · v · r · . · / · :q :h')); state.statusText = null; draw(); return true; }
|
|
1517
|
+
state.statusText = cmd ? c.dim(` Not an ex command: ${cmd} (:q quits, :h helps)`) : null;
|
|
1518
|
+
draw();
|
|
1519
|
+
setTimeout(() => { if (state.statusText?.includes('Not an ex')) { state.statusText = null; draw(); } }, 1800);
|
|
1520
|
+
return true;
|
|
1521
|
+
}
|
|
1522
|
+
if (str && !key.ctrl && !key.meta && str >= ' ' && state.vimEx.length < 40) {
|
|
1523
|
+
state.vimEx += str; state.statusText = c.dim(` :${state.vimEx}▏`); draw();
|
|
1524
|
+
}
|
|
1525
|
+
return true;
|
|
1526
|
+
}
|
|
1527
|
+
if (state.vimFindTyping) {
|
|
1528
|
+
if (name === 'escape') { state.vimFindTyping = false; state.statusText = null; draw(); return true; }
|
|
1529
|
+
if (name === 'backspace') { state.vimFind.pattern = state.vimFind.pattern.slice(0, -1); state.statusText = c.dim(` /${state.vimFind.pattern}▏`); draw(); return true; }
|
|
1530
|
+
if (name === 'return' || name === 'enter') {
|
|
1531
|
+
state.vimFindTyping = false; state.statusText = null;
|
|
1532
|
+
vimFindNext(1); return true;
|
|
1533
|
+
}
|
|
1534
|
+
if (str && !key.ctrl && !key.meta && str >= ' ' && state.vimFind.pattern.length < 60) {
|
|
1535
|
+
state.vimFind.pattern += str; state.statusText = c.dim(` /${state.vimFind.pattern}▏`); draw();
|
|
1536
|
+
}
|
|
1537
|
+
return true;
|
|
1538
|
+
}
|
|
1539
|
+
// normal + visual: app chords keep working, and Esc still interrupts work.
|
|
1540
|
+
if (name === 'escape' && state.busy) return false;
|
|
1541
|
+
if (key.ctrl || key.meta) {
|
|
1542
|
+
if (key.ctrl && name === 'r' && !state.busy) { recallRedo(); return true; }
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
const n = Math.max(1, Number(state.vimNum) || 1);
|
|
1546
|
+
const clearVim = () => { state.vimOp = null; state.vimNum = ''; state.vimReplace = false; state.vimPendingG = false; };
|
|
1547
|
+
const clamp = (p) => Math.max(0, Math.min(state.input.length, p));
|
|
1548
|
+
const repeat = (fn) => { let p = state.cursor; for (let i = 0; i < n; i++) p = fn(p); return clamp(p); };
|
|
1549
|
+
const motions = {
|
|
1550
|
+
h: (p) => p - 1,
|
|
1551
|
+
l: (p) => p + 1,
|
|
1552
|
+
j: (p) => vimRowMove(p, 1),
|
|
1553
|
+
k: (p) => vimRowMove(p, -1),
|
|
1554
|
+
w: (p) => wordForward(p),
|
|
1555
|
+
e: (p) => wordEnd(p),
|
|
1556
|
+
b: (p) => wordBack(p),
|
|
1557
|
+
0: () => lineStartAt(state.cursor),
|
|
1558
|
+
$: () => lineEndAt(state.cursor),
|
|
1559
|
+
'^': () => { const s = lineStartAt(state.cursor); const m = /^[ \t]*/.exec(state.input.slice(s)); return s + (m ? m[0].length : 0); },
|
|
1560
|
+
G: () => state.input.length,
|
|
1561
|
+
};
|
|
1562
|
+
const deleteRange = (a, b, toInsert, kind = 'chars') => {
|
|
1563
|
+
snapshotUndo();
|
|
1564
|
+
const gone = state.input.slice(a, b);
|
|
1565
|
+
kill(gone, 'back');
|
|
1566
|
+
state.lastChange = { t: 'd', kind, text: gone };
|
|
1567
|
+
state.input = state.input.slice(0, a) + state.input.slice(b);
|
|
1568
|
+
state.cursor = clamp(a);
|
|
1569
|
+
if (toInsert) beginInsert();
|
|
1570
|
+
updateMenu(); draw();
|
|
1571
|
+
};
|
|
1572
|
+
// Enclosing delimiters around the cursor (nesting-aware). inner excludes
|
|
1573
|
+
// the pair itself. Quotes pair with themselves. Null when unbalanced.
|
|
1574
|
+
const pairRange = (open, close, inner) => {
|
|
1575
|
+
const s = state.input;
|
|
1576
|
+
const p = state.cursor;
|
|
1577
|
+
if (open === close) {
|
|
1578
|
+
// On a quote preceded by start/space/opener it opens, else it closes.
|
|
1579
|
+
let a; let b;
|
|
1580
|
+
if (s[p] === open && (p === 0 || /[\s([{]/.test(s[p - 1]))) {
|
|
1581
|
+
a = p; b = s.indexOf(open, p + 1);
|
|
1582
|
+
} else {
|
|
1583
|
+
b = s[p] === open ? p : s.indexOf(open, p);
|
|
1584
|
+
a = b === -1 ? -1 : s.lastIndexOf(open, b - 1);
|
|
1585
|
+
}
|
|
1586
|
+
if (a === -1 || b === -1 || a === b) return null;
|
|
1587
|
+
return inner ? { a: a + 1, b } : { a, b: b + 1 };
|
|
1588
|
+
}
|
|
1589
|
+
let a = -1; let depth = 0;
|
|
1590
|
+
for (let i = Math.min(p, s.length - 1); i >= 0; i--) {
|
|
1591
|
+
if (s[i] === close) depth++;
|
|
1592
|
+
else if (s[i] === open) { if (depth === 0) { a = i; break; } depth--; }
|
|
1593
|
+
}
|
|
1594
|
+
if (a === -1) return null;
|
|
1595
|
+
let b = -1; depth = 0;
|
|
1596
|
+
for (let i = Math.max(p, a); i < s.length; i++) {
|
|
1597
|
+
if (s[i] === open && i !== a) depth++;
|
|
1598
|
+
else if (s[i] === close) { if (depth === 0) { b = i; break; } depth--; }
|
|
1599
|
+
}
|
|
1600
|
+
if (b === -1) return null;
|
|
1601
|
+
return inner ? { a: a + 1, b } : { a, b: b + 1 };
|
|
1602
|
+
};
|
|
1603
|
+
if (state.vimReplace) {
|
|
1604
|
+
if (str && str.length === 1 && str >= ' ') {
|
|
1605
|
+
snapshotUndo();
|
|
1606
|
+
state.input = state.input.slice(0, state.cursor) + str + state.input.slice(state.cursor + 1);
|
|
1607
|
+
}
|
|
1608
|
+
clearVim(); draw();
|
|
1609
|
+
return true;
|
|
1610
|
+
}
|
|
1611
|
+
// In visual mode y/d act at once — before the operator capture below.
|
|
1612
|
+
if (state.vim === 'visual') {
|
|
1613
|
+
if (name === 'escape' || str === 'v') { state.vim = 'normal'; clearVim(); draw(); return true; }
|
|
1614
|
+
if ((str === 'y' || str === 'd') && !state.vimOp) {
|
|
1615
|
+
// Anchor is inclusive: include the char under it.
|
|
1616
|
+
const a = Math.min(state.vimAnchor, state.cursor);
|
|
1617
|
+
const b = Math.max(state.vimAnchor, state.cursor) + 1;
|
|
1618
|
+
if (str === 'y') { kill(state.input.slice(a, Math.min(b, state.input.length)), 'back'); state.vim = 'normal'; state.cursor = clamp(a); clearVim(); draw(); }
|
|
1619
|
+
else { deleteRange(a, Math.min(b, state.input.length), false); state.vim = 'normal'; clearVim(); }
|
|
1620
|
+
return true;
|
|
1621
|
+
}
|
|
1622
|
+
const mvOnly = motions[str];
|
|
1623
|
+
if (mvOnly && !/^[0-9]$/.test(str)) { state.cursor = repeat(mvOnly); clearVim(); draw(); return true; }
|
|
1624
|
+
return true; // swallow the rest in visual
|
|
1625
|
+
}
|
|
1626
|
+
// dd / yy line-wise doubling.
|
|
1627
|
+
if ((str === 'd' || str === 'y') && state.vimOp === str) {
|
|
1628
|
+
const a = lineStartAt(state.cursor);
|
|
1629
|
+
let b = lineEndAt(state.cursor);
|
|
1630
|
+
const takeNl = state.input[b] === '\n' ? 1 : 0;
|
|
1631
|
+
if (str === 'd') deleteRange(a, b + takeNl, false, 'line');
|
|
1632
|
+
else { kill(state.input.slice(a, b + takeNl), 'back'); state.cursor = clamp(a); draw(); }
|
|
1633
|
+
clearVim();
|
|
1634
|
+
return true;
|
|
1635
|
+
}
|
|
1636
|
+
if ((str === 'd' || str === 'y' || str === 'c') && !state.vimOp) { state.vimOp = str; return true; }
|
|
1637
|
+
if (str === 'g' && !state.vimPendingG && !state.vimOp && state.vim === 'normal') { state.vimPendingG = true; return true; }
|
|
1638
|
+
if (str === 'i' && state.vimOp === null && state.vimNum === '') { beginInsert(); clearVim(); draw(); return true; }
|
|
1639
|
+
if (str === 'v' && !state.vimOp) { state.vim = 'visual'; state.vimAnchor = state.cursor; clearVim(); draw(); return true; }
|
|
1640
|
+
// iw / aw after d/y/c.
|
|
1641
|
+
if (state.vimOp && (str === 'i' || str === 'a')) {
|
|
1642
|
+
state.vimPendingObj = str;
|
|
1643
|
+
return true;
|
|
1644
|
+
}
|
|
1645
|
+
if (state.vimPendingObj && (str === 'w')) {
|
|
1646
|
+
const obj = state.vimPendingObj; state.vimPendingObj = null;
|
|
1647
|
+
const p = state.cursor;
|
|
1648
|
+
let a; let b;
|
|
1649
|
+
if (isWordCharEdit(state.input[p]) || (obj === 'a' && p < state.input.length)) {
|
|
1650
|
+
a = wordBack(p); b = wordEnd(p) + 1;
|
|
1651
|
+
if (obj === 'a') { while (b < state.input.length && /[ \t]/.test(state.input[b])) b++; }
|
|
1652
|
+
} else { a = p; b = wordForward(p); }
|
|
1653
|
+
a = clamp(a); b = clamp(b);
|
|
1654
|
+
if (state.vimOp === 'y') { kill(state.input.slice(a, b), 'back'); state.cursor = a; draw(); }
|
|
1655
|
+
else deleteRange(a, b, state.vimOp === 'c', 'word');
|
|
1656
|
+
clearVim();
|
|
1657
|
+
return true;
|
|
1658
|
+
}
|
|
1659
|
+
// Quote/paren text objects: i" a" i' i( a( i[ i{ and kin.
|
|
1660
|
+
if (state.vimPendingObj && ['"', "'", '(', ')', '[', ']', '{', '}'].includes(str)) {
|
|
1661
|
+
const obj = state.vimPendingObj; state.vimPendingObj = null;
|
|
1662
|
+
const pairs = { '(': ['(', ')'], ')': ['(', ')'], '[': ['[', ']'], ']': ['[', ']'], '{': ['{', '}'], '}': ['{', '}'], '"': ['"', '"'], "'": ["'", "'"] };
|
|
1663
|
+
const [open, close] = pairs[str];
|
|
1664
|
+
const range = pairRange(open, close, obj === 'i');
|
|
1665
|
+
if (!range) { state.statusText = c.dim(' No enclosing pair'); draw(); setTimeout(() => { if (state.statusText?.includes('No enclosing')) { state.statusText = null; draw(); } }, 1400); clearVim(); return true; }
|
|
1666
|
+
state.statusText = null;
|
|
1667
|
+
if (state.vimOp === 'y') { kill(state.input.slice(range.a, range.b), 'back'); state.cursor = clamp(range.a); draw(); }
|
|
1668
|
+
else deleteRange(range.a, range.b, state.vimOp === 'c', 'chars');
|
|
1669
|
+
clearVim();
|
|
1670
|
+
return true;
|
|
1671
|
+
}
|
|
1672
|
+
state.vimPendingObj = null;
|
|
1673
|
+
if (/^[0-9]$/.test(str)) {
|
|
1674
|
+
if (str === '0' && !state.vimNum && !state.vimOp) { state.cursor = lineStartAt(state.cursor); draw(); return true; }
|
|
1675
|
+
state.vimNum += str;
|
|
1676
|
+
return true;
|
|
1677
|
+
}
|
|
1678
|
+
const mv = motions[str];
|
|
1679
|
+
if (mv && !state.vimOp) {
|
|
1680
|
+
if (str === 'G' && state.vimNum === '') { state.cursor = state.input.length; }
|
|
1681
|
+
else if (str === 'G') {
|
|
1682
|
+
const lines = state.input.split('\n');
|
|
1683
|
+
const li = Math.max(0, Math.min(lines.length - 1, (Number(state.vimNum) || lines.length) - 1));
|
|
1684
|
+
state.cursor = lines.slice(0, li).join('\n').length + (li ? 1 : 0);
|
|
1685
|
+
}
|
|
1686
|
+
else state.cursor = repeat(mv);
|
|
1687
|
+
clearVim(); draw();
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
if (str === 'g' && state.vimPendingG) { state.cursor = 0; clearVim(); draw(); return true; }
|
|
1691
|
+
if (mv && state.vimOp) {
|
|
1692
|
+
let target = repeat(mv);
|
|
1693
|
+
let a = Math.min(state.cursor, target);
|
|
1694
|
+
let b = Math.max(state.cursor, target);
|
|
1695
|
+
if (str === 'e') b = Math.min(state.input.length, b + 1);
|
|
1696
|
+
if (str === 'w' && state.vimOp === 'c') { b = Math.min(state.input.length, wordEnd(state.cursor) + 1); a = Math.min(state.cursor, b); }
|
|
1697
|
+
if (state.vimOp === 'y') { kill(state.input.slice(a, b), 'back'); state.cursor = a; draw(); }
|
|
1698
|
+
else deleteRange(a, b, state.vimOp === 'c', str === 'w' ? 'word' : 'chars');
|
|
1699
|
+
clearVim();
|
|
1700
|
+
return true;
|
|
1701
|
+
}
|
|
1702
|
+
switch (str) {
|
|
1703
|
+
case 'x': if (state.cursor < state.input.length) deleteRange(state.cursor, Math.min(state.input.length, state.cursor + n), false); clearVim(); return true;
|
|
1704
|
+
case 'D': deleteRange(state.cursor, lineEndAt(state.cursor), false); clearVim(); return true;
|
|
1705
|
+
case 'C': deleteRange(state.cursor, lineEndAt(state.cursor), true); clearVim(); return true;
|
|
1706
|
+
case 'S': { const a = lineStartAt(state.cursor); let b = lineEndAt(state.cursor); deleteRange(a, b + (state.input[b] === '\n' ? 1 : 0), true, 'line'); clearVim(); return true; }
|
|
1707
|
+
case 's': if (state.input.length) deleteRange(state.cursor, Math.min(state.input.length, state.cursor + 1), true); else { beginInsert(); draw(); } clearVim(); return true;
|
|
1708
|
+
case 'u': recallUndo(); clearVim(); return true;
|
|
1709
|
+
case 'p': if (state.lastKill) { snapshotUndo(); const at = Math.min(state.input.length, state.cursor + (state.input.length ? 1 : 0)); state.input = state.input.slice(0, at) + state.lastKill + state.input.slice(at); state.cursor = at + state.lastKill.length - 1; state.lastChange = { t: 'i', text: state.lastKill, session: -1 }; updateMenu(); draw(); } clearVim(); return true;
|
|
1710
|
+
case 'P': if (state.lastKill) { snapshotUndo(); state.input = state.input.slice(0, state.cursor) + state.lastKill + state.input.slice(state.cursor); state.cursor += state.lastKill.length; state.lastChange = { t: 'i', text: state.lastKill, session: -1 }; updateMenu(); draw(); } clearVim(); return true;
|
|
1711
|
+
case 'r': state.vimReplace = true; return true;
|
|
1712
|
+
case 'A': state.cursor = state.input.length; beginInsert(); clearVim(); draw(); return true;
|
|
1713
|
+
case 'I': state.cursor = lineStartAt(state.cursor); beginInsert(); clearVim(); draw(); return true;
|
|
1714
|
+
case 'a': state.cursor = clamp(state.cursor + 1); beginInsert(); clearVim(); draw(); return true;
|
|
1715
|
+
case 'o': { const e = lineEndAt(state.cursor); snapshotUndo(); state.input = state.input.slice(0, e) + '\n' + state.input.slice(e); state.cursor = e + 1; beginInsert(); clearVim(); updateMenu(); draw(); return true; }
|
|
1716
|
+
case 'O': { const s = lineStartAt(state.cursor); snapshotUndo(); state.input = state.input.slice(0, s) + '\n' + state.input.slice(s); state.cursor = s; beginInsert(); clearVim(); updateMenu(); draw(); return true; }
|
|
1717
|
+
case '.': { // repeat last change
|
|
1718
|
+
const last = state.lastChange;
|
|
1719
|
+
if (last?.t === 'i' && last.text) { snapshotUndo(); state.input = state.input.slice(0, state.cursor) + last.text + state.input.slice(state.cursor); state.cursor += last.text.length; updateMenu(); }
|
|
1720
|
+
else if (last?.t === 'd') {
|
|
1721
|
+
if (last.kind === 'word') deleteRange(state.cursor, wordForward(state.cursor), false, 'word');
|
|
1722
|
+
else if (last.kind === 'line') { const a = lineStartAt(state.cursor); let b = lineEndAt(state.cursor); deleteRange(a, b + (state.input[b] === '\n' ? 1 : 0), false, 'line'); }
|
|
1723
|
+
else if (last.text) deleteRange(state.cursor, Math.min(state.input.length, state.cursor + [...last.text].length), false);
|
|
1724
|
+
}
|
|
1725
|
+
clearVim(); draw(); return true;
|
|
1726
|
+
}
|
|
1727
|
+
case ':': state.vimEx = ''; state.statusText = c.dim(' :▏'); draw(); return true;
|
|
1728
|
+
case '/': state.vimFind = state.vimFind || { pattern: '' }; state.vimFindTyping = true; state.statusText = c.dim(` /${state.vimFind.pattern}▏`); draw(); return true;
|
|
1729
|
+
case 'n': if (state.vimFind?.pattern && !state.vimFindTyping) { vimFindNext(1); clearVim(); return true; } break;
|
|
1730
|
+
case 'N': if (state.vimFind?.pattern && !state.vimFindTyping) { vimFindNext(-1); clearVim(); return true; } break;
|
|
1731
|
+
default: break;
|
|
1732
|
+
}
|
|
1733
|
+
state.vimPendingG = false;
|
|
1734
|
+
return true; // swallow unknown keys in normal mode (never type raw text)
|
|
1735
|
+
}
|
|
1736
|
+
/** Draft find (`/pattern`, n/N): wraps once around the input. */
|
|
1737
|
+
function vimFindNext(dir) {
|
|
1738
|
+
const pat = state.vimFind?.pattern || '';
|
|
1739
|
+
if (!pat) return;
|
|
1740
|
+
const s = state.input;
|
|
1741
|
+
let idx = -1;
|
|
1742
|
+
if (dir > 0) {
|
|
1743
|
+
idx = s.indexOf(pat, state.cursor + 1);
|
|
1744
|
+
if (idx === -1) idx = s.indexOf(pat, 0);
|
|
1745
|
+
} else {
|
|
1746
|
+
idx = s.lastIndexOf(pat, state.cursor - 1);
|
|
1747
|
+
if (idx === -1) idx = s.lastIndexOf(pat);
|
|
1748
|
+
}
|
|
1749
|
+
if (idx === -1) {
|
|
1750
|
+
state.statusText = c.dim(` Pattern not found: ${pat}`);
|
|
1751
|
+
draw();
|
|
1752
|
+
setTimeout(() => { if (state.statusText?.includes('Pattern not found')) { state.statusText = null; draw(); } }, 1500);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
state.cursor = idx;
|
|
1756
|
+
state.statusText = null;
|
|
1757
|
+
draw();
|
|
1758
|
+
}
|
|
1759
|
+
function vimRowMove(pos, dir) {
|
|
1760
|
+
const rows = visualRows();
|
|
1761
|
+
let idx = 0;
|
|
1762
|
+
for (let i = 0; i < rows.length; i++) {
|
|
1763
|
+
if (pos >= rows[i].start && pos <= rows[i].start + [...rows[i].text].length) { idx = i; break; }
|
|
1764
|
+
if (i === rows.length - 1) idx = i;
|
|
1765
|
+
}
|
|
1766
|
+
const next = Math.max(0, Math.min(rows.length - 1, idx + dir));
|
|
1767
|
+
const col = pos - rows[idx].start;
|
|
1768
|
+
return Math.max(0, Math.min(rows[next].start + [...rows[next].text].length, rows[next].start + col));
|
|
1769
|
+
}
|
|
1770
|
+
function finishDialog(value) {
|
|
1771
|
+
const dialog = state.dialog; state.dialog = null; draw();
|
|
1772
|
+
// Null stays null (cancel / keep-current) — only undefined means deny.
|
|
1773
|
+
dialog?.resolve({ value: value === undefined ? 'deny' : value, comment: (dialog.comment || '').trim() });
|
|
1774
|
+
}
|
|
1775
|
+
function parseMouse(seq) {
|
|
1776
|
+
// SGR: ESC [ < Cb ; Cx ; Cy M/m — M press/drag/wheel, m release.
|
|
1777
|
+
let m = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(seq || '');
|
|
1778
|
+
if (m) {
|
|
1779
|
+
const [, b, x, y, s] = m;
|
|
1780
|
+
return { button: Number(b), col: Number(x), row: Number(y), press: s === 'M' };
|
|
1781
|
+
}
|
|
1782
|
+
// Legacy X10: ESC [ M Cb Cx Cy (bytes +32). Wheel = 64/65.
|
|
1783
|
+
if (seq && seq.startsWith('\x1b[M') && seq.length >= 6) {
|
|
1784
|
+
const b = seq.charCodeAt(3) - 32; const x = seq.charCodeAt(4) - 32; const y = seq.charCodeAt(5) - 32;
|
|
1785
|
+
return { button: b, col: x, row: y, press: true, legacy: true };
|
|
1786
|
+
}
|
|
1787
|
+
return null;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
/** Which menu row a bottom-region row points at (-1 = none). */
|
|
1791
|
+
function menuIndexAtRow(row, w, h) {
|
|
1792
|
+
if (!state.menu.length || state.dialog || state.panel || state.viewingTranscript) return -1;
|
|
1793
|
+
const bottom = bottomRows(contentWidth(w));
|
|
1794
|
+
const rel = row - (h - bottom.length + 1);
|
|
1795
|
+
return (rel >= 0 && rel < state.menu.length) ? rel : -1;
|
|
1796
|
+
}
|
|
1797
|
+
/** Which dialog option a bottom-region cell points at (-1 = none).
|
|
1798
|
+
* Pills panels hit-test by column inside their single row; lists by row. */
|
|
1799
|
+
function dialogOptionAt(dialog, row, col, w, h) {
|
|
1800
|
+
if (!dialog) return -1;
|
|
1801
|
+
if (isPills(dialog)) {
|
|
1802
|
+
const bottom = bottomRows(contentWidth(w));
|
|
1803
|
+
const rel = row - (h - bottom.length + 1);
|
|
1804
|
+
const qLines = dialogQLines(dialog, contentWidth(w));
|
|
1805
|
+
const previewLines = dialog.preview?.length ? 1 + Math.min(7, dialog.preview.length) : 0;
|
|
1806
|
+
const pillsRel = 1 + qLines + previewLines + (dialog.hideComment ? 0 : 1);
|
|
1807
|
+
if (rel !== pillsRel) return -1;
|
|
1808
|
+
for (const s of dialogPillSpans(dialog)) if (col >= s.start && col <= s.end) return s.index;
|
|
1809
|
+
return -1;
|
|
1810
|
+
}
|
|
1811
|
+
return dialogIndexAtRow(row, w, h);
|
|
1812
|
+
}
|
|
1813
|
+
/** Which dialog option a bottom-region row points at (-1 = none). */
|
|
1814
|
+
function dialogIndexAtRow(row, w, h) {
|
|
1815
|
+
if (!state.dialog) return -1;
|
|
1816
|
+
const cw = contentWidth(w);
|
|
1817
|
+
const bottom = bottomRows(cw);
|
|
1818
|
+
const rel = row - (h - bottom.length + 1);
|
|
1819
|
+
const qLines = cleanLine(` ${state.dialog.question}`, Math.max(12, cw - 4)).length;
|
|
1820
|
+
const previewLines = state.dialog.preview?.length ? 1 + Math.min(7, state.dialog.preview.length) : 0;
|
|
1821
|
+
let optStart = 2 + qLines + previewLines + (state.dialog.hideComment ? 0 : 1);
|
|
1822
|
+
const total = state.dialog.options.length;
|
|
1823
|
+
const top = total <= 9 ? 0 : Math.max(0, Math.min(total - 9, state.dialog.index - 4));
|
|
1824
|
+
if (top > 0) optStart += 1; // "more above" hint row
|
|
1825
|
+
const idx = top + (rel - optStart);
|
|
1826
|
+
return (rel - optStart >= 0 && idx >= top && idx < Math.min(total, top + 9)) ? idx : -1;
|
|
1827
|
+
}
|
|
1828
|
+
function handleMouse(ev) {
|
|
1829
|
+
const w = width(); const h = height();
|
|
1830
|
+
const metrics = columnMetrics(w);
|
|
1831
|
+
const cw = metrics.center;
|
|
1832
|
+
const { button, col, row } = ev;
|
|
1833
|
+
const centerCol = col - (metrics.left ? metrics.left + 1 : 0);
|
|
1834
|
+
// Wheel always scrolls (even with clicks disabled), with
|
|
1835
|
+
// acceleration: fast spins cover more ground (§4.2, off via wheelAccel).
|
|
1836
|
+
if (button === 64 || button === 65) {
|
|
1837
|
+
const dir = button === 64 ? 1 : -1;
|
|
1838
|
+
let per = 3;
|
|
1839
|
+
try {
|
|
1840
|
+
if (loadSettings().wheelAccel !== false) {
|
|
1841
|
+
const now = Date.now();
|
|
1842
|
+
state.wheelStreak = now - state.lastWheelAt < 200 ? Math.min(state.wheelStreak + 1, 6) : 0;
|
|
1843
|
+
state.lastWheelAt = now;
|
|
1844
|
+
per = Math.round(3 * (1 + state.wheelStreak * 0.2));
|
|
1845
|
+
}
|
|
1846
|
+
} catch { /* base rate */ }
|
|
1847
|
+
if (state.panel) panelScrollBy(dir * per);
|
|
1848
|
+
else scrollBy(dir * per);
|
|
1849
|
+
return true;
|
|
1850
|
+
}
|
|
1851
|
+
if (mouseClicksDisabled()) return true; // swallow clicks, keep wheel
|
|
1852
|
+
const btn = button & 0x43; // strip modifier bits for press/drag
|
|
1853
|
+
// Hover (any-motion, no button held) highlights rows without acting.
|
|
1854
|
+
if (ev.press && (button & 32) && ((button & 3) === 3) && !state._dragging) {
|
|
1855
|
+
const mi = menuIndexAtRow(row, w, h);
|
|
1856
|
+
if (mi >= 0 && mi !== state.menuIndex) { state.menuIndex = mi; draw(); return true; }
|
|
1857
|
+
const di = state.dialog ? dialogOptionAt(state.dialog, row, centerCol, w, h) : -1;
|
|
1858
|
+
if (di >= 0 && state.dialog && di !== state.dialog.index) { state.dialog.index = di; draw(); return true; }
|
|
1859
|
+
return true;
|
|
1860
|
+
}
|
|
1861
|
+
// Click an option in a dialog to choose it (permission prompts, pickers).
|
|
1862
|
+
if (ev.press && btn === 0 && state.dialog) {
|
|
1863
|
+
const optIdx = dialogOptionAt(state.dialog, row, centerCol, w, h);
|
|
1864
|
+
if (optIdx >= 0) finishDialog(state.dialog.options[optIdx].value);
|
|
1865
|
+
return true;
|
|
1866
|
+
}
|
|
1867
|
+
// Click a suggestion in the / command or @ file list to accept it.
|
|
1868
|
+
if (ev.press && btn === 0 && state.menu.length && !state.dialog && !state.panel && !state.viewingTranscript) {
|
|
1869
|
+
const rel = menuIndexAtRow(row, w, h);
|
|
1870
|
+
if (rel >= 0) {
|
|
1871
|
+
state.menuIndex = rel;
|
|
1872
|
+
acceptMenu();
|
|
1873
|
+
return true;
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
// Drag motion while a press is held: extend, never re-anchor. Dragging
|
|
1877
|
+
// past the viewport edge scrolls that way with the head pinned to the
|
|
1878
|
+
// newly revealed edge, so selecting-while-scrolling just works.
|
|
1879
|
+
const dragScrollEdge = () => {
|
|
1880
|
+
if (state.panel || state.viewingTranscript) return false;
|
|
1881
|
+
const bottom = bottomRows(cw);
|
|
1882
|
+
const browsing = state.transcriptOffset > 0;
|
|
1883
|
+
const bodyFirst = 2 + (browsing ? 1 : 0) + 1; // 1-based first body row
|
|
1884
|
+
const space = Math.max(1, h - 2 - bottom.length - (browsing ? 1 : 0));
|
|
1885
|
+
const step = Math.max(2, Math.floor(space / 4));
|
|
1886
|
+
if (row < bodyFirst) {
|
|
1887
|
+
const before = state.transcriptOffset;
|
|
1888
|
+
scrollBy(step);
|
|
1889
|
+
if (state.transcriptOffset === before) return false;
|
|
1890
|
+
const content = transcriptContent(cw);
|
|
1891
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
1892
|
+
state.selection.head = { line: Math.max(0, end - space), col: 0 };
|
|
1893
|
+
draw();
|
|
1894
|
+
return true;
|
|
1895
|
+
}
|
|
1896
|
+
if (row > bodyFirst + space - 1 && state.transcriptOffset > 0) {
|
|
1897
|
+
scrollBy(-step);
|
|
1898
|
+
const content = transcriptContent(cw);
|
|
1899
|
+
const last = Math.max(0, content.length - state.transcriptOffset - 1);
|
|
1900
|
+
state.selection.head = { line: last, col: [...String(content[last] || '')].length };
|
|
1901
|
+
draw();
|
|
1902
|
+
return true;
|
|
1903
|
+
}
|
|
1904
|
+
return false;
|
|
1905
|
+
};
|
|
1906
|
+
if (ev.press && state._dragging && state.selection) {
|
|
1907
|
+
const idx = screenRowToContent(row, w, h);
|
|
1908
|
+
if (idx < 0 && dragScrollEdge()) return true;
|
|
1909
|
+
if (idx >= 0) {
|
|
1910
|
+
const content = transcriptContent(contentWidth(w));
|
|
1911
|
+
const plain = String(content[Math.min(idx, content.length - 1)] ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
1912
|
+
const colIdx = Math.max(0, Math.min([...plain].length, centerCol - 1));
|
|
1913
|
+
if (state.selectionMode === 'word') {
|
|
1914
|
+
const { s, e } = wordAt(plain, colIdx);
|
|
1915
|
+
const anchorFirst = state.selection.anchor.line < idx || (state.selection.anchor.line === idx && state.selection.anchor.col <= s);
|
|
1916
|
+
state.selection.head = anchorFirst ? { line: idx, col: e } : { line: idx, col: s };
|
|
1917
|
+
} else if (state.selectionMode === 'line') {
|
|
1918
|
+
state.selection.head = { line: idx, col: colIdx < state.selection.anchor.col ? 0 : [...plain].length };
|
|
1919
|
+
} else {
|
|
1920
|
+
state.selection.head = { line: idx, col: colIdx };
|
|
1921
|
+
}
|
|
1922
|
+
draw();
|
|
1923
|
+
}
|
|
1924
|
+
return true;
|
|
1925
|
+
}
|
|
1926
|
+
// The floating Jump-to-bottom pill is clickable (§4.2).
|
|
1927
|
+
if (ev.press && btn === 0 && !state.panel && !state.viewingTranscript && state.transcriptOffset > 0 && row === 3) {
|
|
1928
|
+
scrollTo('bottom');
|
|
1929
|
+
return true;
|
|
1930
|
+
}
|
|
1931
|
+
// Press / drag start.
|
|
1932
|
+
if (ev.press && (btn === 0 || (button & 64))) {
|
|
1933
|
+
const now = Date.now();
|
|
1934
|
+
const sameSpot = state.lastClickPos && Math.abs(state.lastClickPos.col - col) <= 1 && Math.abs(state.lastClickPos.row - row) <= 1;
|
|
1935
|
+
state.clickCount = (sameSpot && now - state.lastClickAt < 500) ? state.clickCount + 1 : 1;
|
|
1936
|
+
state.lastClickAt = now; state.lastClickPos = { col, row };
|
|
1937
|
+
// Click in the prompt input positions the cursor (wrap-aware).
|
|
1938
|
+
if (!state.viewingTranscript && !state.panel && !state.dialog) {
|
|
1939
|
+
const bottom = bottomRows(cw);
|
|
1940
|
+
const menuN = state.menu.length;
|
|
1941
|
+
const queuedN = state.queued.length ? 1 : 0;
|
|
1942
|
+
const firstPrompt = h - bottom.length + 1 + menuN + queuedN + 1; // 1-based
|
|
1943
|
+
if (row >= firstPrompt && row < h) {
|
|
1944
|
+
const usable = Math.max(12, cw - PROMPT_USABLE_SLACK);
|
|
1945
|
+
if (!state.input) { state.cursor = 0; draw(); return true; }
|
|
1946
|
+
const rows = visualRows(usable);
|
|
1947
|
+
const rowIdx = Math.max(0, Math.min(rows.length - 1, row - firstPrompt));
|
|
1948
|
+
const prefix = rowIdx === 0 ? PROMPT_PREFIX_WIDTH : [...PROMPT_CONT].length;
|
|
1949
|
+
const colInRow = Math.max(0, centerCol - 1 - prefix);
|
|
1950
|
+
const target = rows[rowIdx];
|
|
1951
|
+
state.cursor = Math.max(0, Math.min(state.input.length, target.start + Math.min([...target.text].length, colInRow)));
|
|
1952
|
+
state.clickCount = 0;
|
|
1953
|
+
draw();
|
|
1954
|
+
return true;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
const idx = screenRowToContent(row, w, h);
|
|
1958
|
+
if (idx < 0) return true;
|
|
1959
|
+
const content = transcriptContent(cw);
|
|
1960
|
+
const plain = String(content[idx] ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
1961
|
+
const colIdx = Math.max(0, Math.min([...plain].length, centerCol - 1));
|
|
1962
|
+
if (state.clickCount === 2) {
|
|
1963
|
+
const { s, e } = wordAt(plain, colIdx);
|
|
1964
|
+
state.selection = { anchor: { line: idx, col: s }, head: { line: idx, col: e } };
|
|
1965
|
+
state.selectionMode = 'word';
|
|
1966
|
+
} else if (state.clickCount >= 3) {
|
|
1967
|
+
state.selection = { anchor: { line: idx, col: 0 }, head: { line: idx, col: [...plain].length } };
|
|
1968
|
+
state.selectionMode = 'line';
|
|
1969
|
+
state.clickCount = 0;
|
|
1970
|
+
} else {
|
|
1971
|
+
state.selection = { anchor: { line: idx, col: colIdx }, head: { line: idx, col: colIdx } };
|
|
1972
|
+
state.selectionMode = 'char';
|
|
1973
|
+
}
|
|
1974
|
+
state._dragging = true;
|
|
1975
|
+
draw();
|
|
1976
|
+
return true;
|
|
1977
|
+
}
|
|
1978
|
+
// Release: copy automatically, toast the path.
|
|
1979
|
+
if (!ev.press || btn === 3) {
|
|
1980
|
+
state._dragging = false;
|
|
1981
|
+
if (state.selection) {
|
|
1982
|
+
const text = selectedText();
|
|
1983
|
+
if (text && copyOnSelect()) {
|
|
1984
|
+
const via = copyText(text);
|
|
1985
|
+
toastCopied(via, [...text].length);
|
|
1986
|
+
} else if (text) {
|
|
1987
|
+
state.statusText = `${c.inverse(` Selected ${[...text].length} chars `)}${c.dim(' Ctrl+Shift+C to copy')}`;
|
|
1988
|
+
draw();
|
|
1989
|
+
setTimeout(() => { if (state.statusText?.includes('Selected')) { state.statusText = null; draw(); } }, 2200);
|
|
1990
|
+
} else {
|
|
1991
|
+
state.selection = null;
|
|
1992
|
+
draw();
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
return true;
|
|
1996
|
+
}
|
|
1997
|
+
return true;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
async function handleKey(str, key = {}) {
|
|
2001
|
+
const name = key.name;
|
|
2002
|
+
const rawSequence = key.sequence || str || '';
|
|
2003
|
+
const mouse = parseMouse(rawSequence);
|
|
2004
|
+
if (mouse && !mouseDisabled()) return handleMouse(mouse);
|
|
2005
|
+
if (mouse && mouseDisabled()) {
|
|
2006
|
+
// Capture off: wheel still scrolls via terminal? No — let terminal handle
|
|
2007
|
+
// natively. Swallow nothing so Shift+wheel reaches the terminal.
|
|
2008
|
+
if (mouse.button === 64 || mouse.button === 65) {
|
|
2009
|
+
if (state.panel) panelScrollBy(mouse.button === 64 ? 3 : -3);
|
|
2010
|
+
else scrollBy(mouse.button === 64 ? 3 : -3);
|
|
2011
|
+
}
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
if (key.ctrl && name === 'k' && !state.dialog && !state.panel) {
|
|
2015
|
+
state.commandPalette = state.commandPalette ? null : { query: '', index: 0 };
|
|
2016
|
+
state.menu = []; state.menuIndex = -1;
|
|
2017
|
+
draw();
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (state.commandPalette) {
|
|
2021
|
+
const matches = paletteMatches();
|
|
2022
|
+
if (name === 'escape') { state.commandPalette = null; draw(); return; }
|
|
2023
|
+
if (name === 'up') { state.commandPalette.index = Math.max(0, state.commandPalette.index - 1); draw(); return; }
|
|
2024
|
+
if (name === 'down') { state.commandPalette.index = Math.min(Math.max(0, matches.length - 1), state.commandPalette.index + 1); draw(); return; }
|
|
2025
|
+
if (name === 'backspace') { state.commandPalette.query = state.commandPalette.query.slice(0, -1); state.commandPalette.index = 0; draw(); return; }
|
|
2026
|
+
if (name === 'return' || name === 'enter') {
|
|
2027
|
+
const picked = matches[state.commandPalette.index];
|
|
2028
|
+
if (picked) {
|
|
2029
|
+
state.input = picked[0]; state.cursor = state.input.length;
|
|
2030
|
+
state.commandPalette = null; updateMenu(); draw();
|
|
2031
|
+
}
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') {
|
|
2035
|
+
state.commandPalette.query += str; state.commandPalette.index = 0; draw();
|
|
2036
|
+
}
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
// Selection keep/clear: Esc + paging + modified nav keep, else clear.
|
|
2040
|
+
clearSelectionUnlessKeeps(key, name, str);
|
|
2041
|
+
if (state.panel) {
|
|
2042
|
+
if (name === 'escape' || name === 'q') return closePanel();
|
|
2043
|
+
if (name === 'up' || name === 'k') return panelScrollBy(1);
|
|
2044
|
+
if (name === 'down' || name === 'j') return panelScrollBy(-1);
|
|
2045
|
+
if (name === 'pageup') return panelScrollBy(Math.max(4, Math.floor(height() / 2)));
|
|
2046
|
+
if (name === 'pagedown') return panelScrollBy(-Math.max(4, Math.floor(height() / 2)));
|
|
2047
|
+
if (key.ctrl && name === 'home') { state.panel.offset = Math.max(0, state.panel.lines.length - Math.max(1, height() - 5)); draw(); return; }
|
|
2048
|
+
if (key.ctrl && name === 'end') { state.panel.offset = 0; draw(); return; }
|
|
2049
|
+
if (str === ' ') return panelScrollBy(-Math.max(4, height()));
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (state.historySearch) {
|
|
2053
|
+
if (name === 'escape') return finishHistorySearch(false);
|
|
2054
|
+
if (name === 'return' || name === 'enter') return finishHistorySearch(true);
|
|
2055
|
+
if (name === 'up') { state.historySearch.index = Math.max(0, state.historySearch.index - 1); draw(); return; }
|
|
2056
|
+
if (name === 'down') { state.historySearch.index = Math.min(state.historySearch.matches.length - 1, state.historySearch.index + 1); draw(); return; }
|
|
2057
|
+
if (name === 'backspace') { state.historySearch.query = state.historySearch.query.slice(0, -1); refreshHistorySearch(); return; }
|
|
2058
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') { state.historySearch.query += str; refreshHistorySearch(); }
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (state.viewingTranscript) {
|
|
2062
|
+
if (state.search) {
|
|
2063
|
+
if (name === 'escape') return finishSearch(true);
|
|
2064
|
+
if (name === 'return' || name === 'enter') return finishSearch(false);
|
|
2065
|
+
if (name === 'backspace') { state.search.query = state.search.query.slice(0, -1); focusSearch(state.search); return; }
|
|
2066
|
+
if (name === 'up') return focusSearch(state.search, -1);
|
|
2067
|
+
if (name === 'down') return focusSearch(state.search, 1);
|
|
2068
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') { state.search.query += str; focusSearch(state.search); }
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (name === 'escape' || name === 'q' || (key.ctrl && name === 'o')) {
|
|
2072
|
+
// Keyboard selection cancels first (stays in view); second press exits.
|
|
2073
|
+
if (state.selection && state._kbSelect) { state.selection = null; state._kbSelect = false; draw(); return; }
|
|
2074
|
+
// Returning from a '[' scrollback dump re-enters fullscreen.
|
|
2075
|
+
const wasDumped = state._dumped;
|
|
2076
|
+
state._dumped = false;
|
|
2077
|
+
state.viewingTranscript = false; state.transcriptOffset = 0;
|
|
2078
|
+
if (wasDumped) rawWrite('\x1b[?1049h\x1b[2J\x1b[H');
|
|
2079
|
+
draw(); return;
|
|
2080
|
+
}
|
|
2081
|
+
// Keyboard selection: no mouse needed (conhost never forwards mouse).
|
|
2082
|
+
// 's' starts on the newest visible line, j/k/↑↓ extend, Enter/y/c copies.
|
|
2083
|
+
if (str === 's') {
|
|
2084
|
+
const content = transcriptContent(width());
|
|
2085
|
+
if (!content.length) { state.statusText = c.dim(' Nothing to select yet'); draw(); setTimeout(() => { state.statusText = null; draw(); }, 1400); return; }
|
|
2086
|
+
if (state.selection && state._kbSelect) { state.selection = null; state._kbSelect = false; draw(); return; }
|
|
2087
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
2088
|
+
const idx = Math.max(0, end - 1);
|
|
2089
|
+
const plain = String(content[idx] ?? '').replace(/\x1b\[[0-9;]*m/g, '');
|
|
2090
|
+
state.selection = { anchor: { line: idx, col: 0 }, head: { line: idx, col: [...plain].length } };
|
|
2091
|
+
state.selectionMode = 'line';
|
|
2092
|
+
state._kbSelect = true;
|
|
2093
|
+
state.statusText = c.dim(' j/k extend · Enter/y copy · Esc cancel');
|
|
2094
|
+
draw();
|
|
2095
|
+
setTimeout(() => { if (state.statusText?.includes('j/k extend')) { state.statusText = null; draw(); } }, 2200);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
if ((str === 'y' || str === 'c' || name === 'return' || name === 'enter') && state.selection) {
|
|
2099
|
+
const text = selectedText();
|
|
2100
|
+
if (text) { const via = copyText(text); toastCopied(via, [...text].length); }
|
|
2101
|
+
else { state.selection = null; state._kbSelect = false; draw(); }
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (str === '/') return beginSearch();
|
|
2105
|
+
if (str === '[') { state._dumped = true; dumpToScrollback(); return; }
|
|
2106
|
+
if (str === 'v') { openInEditor(); return; }
|
|
2107
|
+
if (str === '?' && !state.input) {
|
|
2108
|
+
print(c.dim(' Keys: / search · n/N match · j/k/↑↓ line · Ctrl+u/d half · Ctrl+b/f/Space full · {/} prompt · [ scrollback · v editor · q/Esc return'));
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
if (str === 'n' && state.lastSearch) { const search = { query: state.lastSearch, matches: searchMatches(state.lastSearch), index: -1 }; return focusSearch(search, 1); }
|
|
2112
|
+
if (str === 'N' && state.lastSearch) { const search = { query: state.lastSearch, matches: searchMatches(state.lastSearch), index: 0 }; return focusSearch(search, -1); }
|
|
2113
|
+
if (name === 'up' || name === 'k') return scrollBy(1);
|
|
2114
|
+
if (name === 'down' || name === 'j') return scrollBy(-1);
|
|
2115
|
+
if (name === 'pageup' || (key.ctrl && name === 'u')) return scrollBy(Math.max(4, Math.floor(height() / 2)));
|
|
2116
|
+
if (name === 'pagedown' || (key.ctrl && name === 'd')) return scrollBy(-Math.max(4, Math.floor(height() / 2)));
|
|
2117
|
+
if ((key.ctrl && name === 'b') || (str === 'b')) return scrollBy(Math.max(4, height()));
|
|
2118
|
+
if ((key.ctrl && name === 'f') || str === ' ') return scrollBy(-Math.max(4, height()));
|
|
2119
|
+
if (str === '{') return jumpToPrompt(-1);
|
|
2120
|
+
if (str === '}') return jumpToPrompt(1);
|
|
2121
|
+
if (name === 'home' || name === 'g') return scrollTo('top');
|
|
2122
|
+
if (name === 'end' || name === 'G') return scrollTo('bottom');
|
|
2123
|
+
// Manual copy in transcript mode.
|
|
2124
|
+
if ((key.ctrl && key.shift && (name === 'c' || name === 'C')) || (key.ctrl && name === 'c' && state.selection)) {
|
|
2125
|
+
const text = selectedText();
|
|
2126
|
+
if (text) { const via = copyText(text); toastCopied(via, [...text].length); }
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (state.dialog) {
|
|
2132
|
+
const dialog = state.dialog;
|
|
2133
|
+
if (dialog.editing) {
|
|
2134
|
+
if (name === 'escape' || (key.ctrl && name === 'c')) { dialog.editing = false; draw(); return; }
|
|
2135
|
+
if (name === 'return' || name === 'enter') { dialog.editing = false; draw(); return; }
|
|
2136
|
+
if (name === 'backspace') { dialog.comment = (dialog.comment || '').slice(0, -1); draw(); return; }
|
|
2137
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') { dialog.comment = (dialog.comment || '') + str; draw(); }
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (/^[1-9]$/.test(str)) {
|
|
2141
|
+
// 1-9 address the visible window when the list scrolls.
|
|
2142
|
+
const total = dialog.options.length;
|
|
2143
|
+
const top = total <= 9 ? 0 : Math.max(0, Math.min(total - 9, dialog.index - 4));
|
|
2144
|
+
const opt = dialog.options[top + Number(str) - 1];
|
|
2145
|
+
if (opt && top + Number(str) - 1 < top + Math.min(9, total - top)) return finishDialog(opt.value);
|
|
2146
|
+
}
|
|
2147
|
+
if (str === 'e' && !dialog.hideComment) { dialog.editing = true; draw(); return; }
|
|
2148
|
+
if (name === 'up') { dialog.index = Math.max(0, dialog.index - 1); draw(); return; }
|
|
2149
|
+
if (name === 'down') { dialog.index = Math.min(dialog.options.length - 1, dialog.index + 1); draw(); return; }
|
|
2150
|
+
// Pills panels also travel sideways, opencode-style.
|
|
2151
|
+
if (isPills(dialog) && (name === 'left' || str === 'h')) { dialog.index = (dialog.index + dialog.options.length - 1) % dialog.options.length; draw(); return; }
|
|
2152
|
+
if (isPills(dialog) && (name === 'right' || str === 'l')) { dialog.index = (dialog.index + 1) % dialog.options.length; draw(); return; }
|
|
2153
|
+
if (name === 'return' || name === 'enter') return finishDialog(dialog.options[dialog.index].value);
|
|
2154
|
+
// Esc denies where denial is an option, otherwise it cancels (null).
|
|
2155
|
+
if (name === 'escape') return finishDialog(dialog.options.find((option) => option.value === 'deny')?.value ?? null); return;
|
|
2156
|
+
}
|
|
2157
|
+
if (!state.panel && !state.viewingTranscript && !state.historySearch && !state.dialog && handleVim(str, key)) return;
|
|
2158
|
+
if (key.ctrl && name === 'o') { state.viewingTranscript = true; draw(); return; }
|
|
2159
|
+
if (key.ctrl && name === 'r') return beginHistorySearch();
|
|
2160
|
+
const shiftTab = (key.shift && name === 'tab') || key.sequence === '\x1b[Z' || key.sequence === '\x1b[OT';
|
|
2161
|
+
if (shiftTab && onModeCycle) {
|
|
2162
|
+
const mode = await onModeCycle();
|
|
2163
|
+
state.statusText = mode === 'bypassPermissions'
|
|
2164
|
+
? c.red(` ● BYPASS ON — writes and commands run with no permission checks`)
|
|
2165
|
+
: `${c.brand(` ${glyph('◈', '*')}`)} Permission mode: ${c.bold(modeLabel(mode))}`;
|
|
2166
|
+
draw();
|
|
2167
|
+
setTimeout(() => { if (state.statusText?.includes('Permission mode:') || state.statusText?.includes('BYPASS ON')) { state.statusText = null; draw(); } }, 2200);
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
if (name === 'pageup') return scrollBy(Math.max(4, Math.floor(height() / 2)));
|
|
2171
|
+
if (name === 'pagedown') return scrollBy(-Math.max(4, Math.floor(height() / 2)));
|
|
2172
|
+
if (key.ctrl && name === 'home') return scrollTo('top');
|
|
2173
|
+
if (key.ctrl && name === 'end') return scrollTo('bottom');
|
|
2174
|
+
// '?' on empty input toggles the shortcut help.
|
|
2175
|
+
if (str === '?' && !state.input.trim() && !state.menu.length) {
|
|
2176
|
+
print(c.dim(' Keys: Enter send · Ctrl+J / \\+Enter newline · Up/Down history · Ctrl+R search · PgUp/PgDn scroll · wheel scroll · drag select (auto-copy) · Shift+arrows extend · Ctrl+Shift+C copy · [ scrollback (in Ctrl+O) · v editor · ? help · Shift+Tab modes · Esc interrupt · Esc Esc rewind'));
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
// Shift+arrows extend an active selection from the keyboard.
|
|
2180
|
+
if (key.shift && ['up', 'down', 'left', 'right', 'home', 'end'].includes(name) && state.selection) {
|
|
2181
|
+
const content = transcriptContent(width());
|
|
2182
|
+
const head = { ...state.selection.head };
|
|
2183
|
+
if (name === 'left') head.col = Math.max(0, head.col - 1);
|
|
2184
|
+
if (name === 'right') head.col = head.col + 1;
|
|
2185
|
+
if (name === 'home') head.col = 0;
|
|
2186
|
+
if (name === 'end') head.col = [...(String(content[head.line] || '').replace(/\x1b\[[0-9;]*m/g, ''))].length;
|
|
2187
|
+
if (name === 'up') { head.line = Math.max(0, head.line - 1); }
|
|
2188
|
+
if (name === 'down') { head.line = Math.min(content.length - 1, head.line + 1); }
|
|
2189
|
+
state.selection.head = head;
|
|
2190
|
+
// Shift+Up/Down at the edge scrolls the viewport.
|
|
2191
|
+
if (name === 'up' || name === 'down') {
|
|
2192
|
+
const end = Math.max(0, content.length - state.transcriptOffset);
|
|
2193
|
+
if (head.line < end - height() || head.line >= end) scrollBy(name === 'up' ? 1 : -1);
|
|
2194
|
+
else draw();
|
|
2195
|
+
} else draw();
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
if (key.ctrl && name === 'c') {
|
|
2199
|
+
// Ctrl+C copies the selection when one is active, interrupts a running
|
|
2200
|
+
// turn while busy, clears a draft otherwise — and never exits. Leaving
|
|
2201
|
+
// happens only through /exit.
|
|
2202
|
+
if (state.selection) {
|
|
2203
|
+
const text = selectedText();
|
|
2204
|
+
if (text) { const via = copyText(text); toastCopied(via, [...text].length); return; }
|
|
2205
|
+
}
|
|
2206
|
+
if (state.busy) return state.onEscape?.();
|
|
2207
|
+
if (state.input) { snapshotUndo(); state.input = ''; state.cursor = 0; updateMenu(); draw(); return; }
|
|
2208
|
+
state.statusText = c.dim(' Type /exit to leave');
|
|
2209
|
+
draw();
|
|
2210
|
+
setTimeout(() => { if (state.statusText?.includes('/exit to leave')) { state.statusText = null; draw(); } }, 1500);
|
|
2211
|
+
return;
|
|
2212
|
+
}
|
|
2213
|
+
if (key.ctrl && name === 'b') {
|
|
2214
|
+
// Detach an `!` shell line into the background (§16): same as `! cmd &`.
|
|
2215
|
+
if (state.input.startsWith('!') && state.input.trim().length > 1) {
|
|
2216
|
+
if (!/\s&\s*$/.test(state.input)) state.input = `${state.input.trimEnd()} &`;
|
|
2217
|
+
state.cursor = state.input.length;
|
|
2218
|
+
return submit();
|
|
2219
|
+
}
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
// Manual copy when Copy-on-select is off (Ctrl+Shift+C).
|
|
2223
|
+
if (key.ctrl && key.shift && (name === 'c' || name === 'C')) {
|
|
2224
|
+
const text = selectedText();
|
|
2225
|
+
if (text) { const via = copyText(text); toastCopied(via, [...text].length); }
|
|
2226
|
+
else { state.statusText = c.dim(` Nothing selected — drag to select, ${nativeSelectKey()}+drag for native`); draw(); setTimeout(() => { state.statusText = null; draw(); }, 1600); }
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
if (key.ctrl && name === 'd') {
|
|
2230
|
+
if (state.input) { state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + 1); draw(); return; }
|
|
2231
|
+
// No double-press exit: leaving happens only through /exit.
|
|
2232
|
+
state.statusText = c.dim(' Type /exit to leave');
|
|
2233
|
+
draw();
|
|
2234
|
+
setTimeout(() => { if (state.statusText?.includes('/exit to leave')) { state.statusText = null; draw(); } }, 1500);
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (key.ctrl && name === 'l') { draw(); return; }
|
|
2238
|
+
// Ctrl+V pastes a clipboard image as a positional chip (§3.5);
|
|
2239
|
+
// Alt+V where the terminal steals Ctrl+V (Windows/WSL).
|
|
2240
|
+
if ((key.ctrl && name === 'v') || (key.meta && name === 'v')) { pasteImageFromClipboard(); return; }
|
|
2241
|
+
if (key.ctrl && name === 'g') {
|
|
2242
|
+
// Ctrl+G: open the draft in $VISUAL/$EDITOR.
|
|
2243
|
+
const editor = process.env.VISUAL || process.env.EDITOR;
|
|
2244
|
+
if (editor) {
|
|
2245
|
+
const file = path.join(os.tmpdir(), `jaitus-prompt-${Date.now()}.md`);
|
|
2246
|
+
try {
|
|
2247
|
+
fs.writeFileSync(file, state.input);
|
|
2248
|
+
rawWrite('\x1b[?1049l');
|
|
2249
|
+
spawnSync(editor, [file], { stdio: 'inherit' });
|
|
2250
|
+
rawWrite('\x1b[?1049h\x1b[2J\x1b[H');
|
|
2251
|
+
state.input = fs.readFileSync(file, 'utf8');
|
|
2252
|
+
state.cursor = state.input.length;
|
|
2253
|
+
try { fs.rmSync(file); } catch { /* temp */ }
|
|
2254
|
+
updateMenu(); draw();
|
|
2255
|
+
} catch { draw(); }
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
if (key.ctrl && name === 't') {
|
|
2260
|
+
// Ctrl+T: toggle the task/activity view.
|
|
2261
|
+
const activity = state.activity.map((item) => ({ ...item }));
|
|
2262
|
+
if (!activity.length) { state.statusText = c.dim(' No agent tool activity in this session yet'); draw(); setTimeout(() => { state.statusText = null; draw(); }, 1600); return; }
|
|
2263
|
+
const lines = activity.flatMap((item) => [
|
|
2264
|
+
`${item.status === 'ok' ? '✓' : item.status === 'failed' ? '✗' : '…'} ${item.label}`,
|
|
2265
|
+
...(item.detail ? String(item.detail).split('\n').slice(0, 12).map((l) => ` ${l}`) : []),
|
|
2266
|
+
'',
|
|
2267
|
+
]);
|
|
2268
|
+
showPanel('Session activity', lines);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (key.ctrl && name === 'a') { state.cursor = 0; draw(); return; }
|
|
2272
|
+
if (key.ctrl && name === 'e') { state.cursor = state.input.length; draw(); return; }
|
|
2273
|
+
if (key.ctrl && name === 'u') { snapshotUndo(); kill(state.input.slice(0, state.cursor), 'back'); state.input = state.input.slice(state.cursor); state.cursor = 0; updateMenu(); draw(); return; }
|
|
2274
|
+
if (key.ctrl && name === 'k') { snapshotUndo(); kill(state.input.slice(state.cursor), 'forward'); state.input = state.input.slice(0, state.cursor); updateMenu(); draw(); return; }
|
|
2275
|
+
if ((key.meta && name === 'backspace') || (key.ctrl && name === 'w')) { snapshotUndo(); const previous = state.input.slice(0, state.cursor).replace(/\s*\S+\s*$/, ''); kill(state.input.slice(previous.length, state.cursor), 'back'); state.input = previous + state.input.slice(state.cursor); state.cursor = previous.length; updateMenu(); draw(); return; }
|
|
2276
|
+
if (key.ctrl && name === 'j') return insert('\n');
|
|
2277
|
+
// Shift+Enter (kitty `CSI 13;2 u`) and Alt+Enter insert a newline anywhere.
|
|
2278
|
+
if (rawSequence === '\x1b[13;2u' || rawSequence === '\x1b[13u' || (key.meta && (name === 'return' || name === 'enter'))) return insert('\n');
|
|
2279
|
+
if (key.meta && name === 'b') { state.cursor = wordBack(state.cursor); draw(); return; }
|
|
2280
|
+
if (key.meta && name === 'f') { state.cursor = wordForward(state.cursor); draw(); return; }
|
|
2281
|
+
if (key.meta && name === 'd') {
|
|
2282
|
+
snapshotUndo();
|
|
2283
|
+
let i = state.cursor;
|
|
2284
|
+
while (i < state.input.length && !isWordCharEdit(state.input[i])) i++;
|
|
2285
|
+
const start = i;
|
|
2286
|
+
while (i < state.input.length && isWordCharEdit(state.input[i])) i++;
|
|
2287
|
+
kill(state.input.slice(start, i), 'forward');
|
|
2288
|
+
state.input = state.input.slice(0, state.cursor) + state.input.slice(i);
|
|
2289
|
+
updateMenu(); draw(); return;
|
|
2290
|
+
}
|
|
2291
|
+
if (key.ctrl && name === 'y') return yank();
|
|
2292
|
+
if (key.ctrl && name === '_') return recallUndo();
|
|
2293
|
+
if (key.ctrl && (name === 'p' || name === 'n')) { /* fall through to up/down below */ }
|
|
2294
|
+
else if (key.ctrl && name === 's') {
|
|
2295
|
+
// Stash the draft; again on empty restores it (§3.9).
|
|
2296
|
+
if (state.input) { state.stash = { input: state.input, cursor: state.cursor }; state.input = ''; state.cursor = 0; updateMenu(); draw(); }
|
|
2297
|
+
else if (state.stash) { state.input = state.stash.input; state.cursor = Math.min(state.stash.cursor, state.input.length); state.stash = null; updateMenu(); draw(); }
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (name === 'backspace') {
|
|
2301
|
+
if (state.cursor) {
|
|
2302
|
+
snapshotUndo('type');
|
|
2303
|
+
// A paste chip deletes as one unit (and stays paste-back-able, §3.4).
|
|
2304
|
+
const before = state.input.slice(0, state.cursor);
|
|
2305
|
+
const chip = /(\[Pasted text #\d+ \+\d+ lines?\]|\[Image #\d+\])$/.exec(before);
|
|
2306
|
+
if (chip) {
|
|
2307
|
+
kill(chip[1], 'back');
|
|
2308
|
+
state.input = before.slice(0, -chip[1].length) + state.input.slice(state.cursor);
|
|
2309
|
+
state.cursor -= chip[1].length;
|
|
2310
|
+
} else {
|
|
2311
|
+
state.input = before.slice(0, -1) + state.input.slice(state.cursor);
|
|
2312
|
+
state.cursor -= 1;
|
|
2313
|
+
}
|
|
2314
|
+
updateMenu(); draw();
|
|
2315
|
+
}
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
if (name === 'delete') { snapshotUndo('type'); state.input = state.input.slice(0, state.cursor) + state.input.slice(state.cursor + 1); updateMenu(); draw(); return; }
|
|
2319
|
+
if (name === 'left') { state.cursor = Math.max(0, state.cursor - 1); draw(); return; }
|
|
2320
|
+
if (name === 'right') { state.cursor = Math.min(state.input.length, state.cursor + 1); draw(); return; }
|
|
2321
|
+
if (name === 'home') { state.cursor = 0; draw(); return; }
|
|
2322
|
+
if (name === 'end') { state.cursor = state.input.length; draw(); return; }
|
|
2323
|
+
if (name === 'up' || (key.ctrl && name === 'p')) {
|
|
2324
|
+
if (state.menu.length) { state.menuIndex = Math.max(0, state.menuIndex - 1); draw(); return; }
|
|
2325
|
+
if (!state.input && state.queued.length) { state.input = state.queued.pop(); state.cursor = state.input.length; updateMenu(); draw(); return; }
|
|
2326
|
+
// Multiline first: move within the prompt, then history at the edges.
|
|
2327
|
+
if (state.input.includes('\n')) {
|
|
2328
|
+
const w = width(); const usable = Math.max(12, w - PROMPT_USABLE_SLACK);
|
|
2329
|
+
const rows = cleanLine(state.input.slice(0, state.cursor), usable);
|
|
2330
|
+
if (rows.length > 1) {
|
|
2331
|
+
const lastLen = visibleWidth(rows[rows.length - 1] || '');
|
|
2332
|
+
const prevLen = visibleWidth(rows[rows.length - 2] || '');
|
|
2333
|
+
state.cursor = Math.max(0, state.cursor - Math.max(1, lastLen) - 1);
|
|
2334
|
+
void prevLen;
|
|
2335
|
+
draw(); return;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
historyMove(-1); return;
|
|
2339
|
+
}
|
|
2340
|
+
if (name === 'down' || (key.ctrl && name === 'n')) {
|
|
2341
|
+
if (state.menu.length) { state.menuIndex = Math.min(state.menu.length - 1, state.menuIndex + 1); draw(); return; }
|
|
2342
|
+
if (state.input.includes('\n')) {
|
|
2343
|
+
const after = state.input.slice(state.cursor);
|
|
2344
|
+
if (after.includes('\n')) { state.cursor = Math.min(state.input.length, state.cursor + 1 + visibleWidth(cleanLine(state.input.slice(0, state.cursor), 80).at(-1) || '')); draw(); return; }
|
|
2345
|
+
}
|
|
2346
|
+
historyMove(1); return;
|
|
2347
|
+
}
|
|
2348
|
+
if (name === 'tab') { if (state.menu.length) acceptMenu(); return; }
|
|
2349
|
+
if (name === 'escape') {
|
|
2350
|
+
if (state.busy) return state.onEscape?.();
|
|
2351
|
+
// Esc first dismisses the suggestion menu.
|
|
2352
|
+
if (state.menu.length) { state.menu = []; state.menuIndex = -1; draw(); return; }
|
|
2353
|
+
if (state.input) { state.input = ''; state.cursor = 0; updateMenu(); draw(); return; }
|
|
2354
|
+
// Esc+Esc when idle: rewind to an earlier turn.
|
|
2355
|
+
if (state.lastEscapeAt && Date.now() - state.lastEscapeAt < 1200 && onRewind) {
|
|
2356
|
+
state.lastEscapeAt = null;
|
|
2357
|
+
Promise.resolve(onRewind()).catch((error) => print(c.red(` ${error?.message || error}`)));
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
state.lastEscapeAt = Date.now();
|
|
2361
|
+
state.statusText = `${c.dim(' ↩')} Press Esc again to rewind…`;
|
|
2362
|
+
draw();
|
|
2363
|
+
setTimeout(() => { if (state.statusText?.includes('Press Esc again')) { state.statusText = null; draw(); } }, 1200);
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
if (name === 'return' || name === 'enter') {
|
|
2367
|
+
if (state.input.endsWith('\\')) { state.input = `${state.input.slice(0, -1)}\n`; state.cursor = state.input.length; updateMenu(); draw(); return; }
|
|
2368
|
+
if (state.busy) { if (state.input.trim()) { state.queued.push(state.input); state.input = ''; state.cursor = 0; state.menu = []; state.menuIndex = -1; draw(); } return; }
|
|
2369
|
+
// Tab accepts, Enter runs the highlighted suggestion in ONE press.
|
|
2370
|
+
if (state.menu.length && state.menuIndex >= 0) {
|
|
2371
|
+
const item = state.menu[state.menuIndex];
|
|
2372
|
+
// An emoji pick lands in the draft; everything else sends immediately.
|
|
2373
|
+
if (item?.kind === 'emoji' && !/(?:^|\s)\//.test(state.input)) return acceptMenu();
|
|
2374
|
+
if (item && (item.kind === 'command' || item.kind === 'history' || /(?:^|\s)\//.test(state.input))) {
|
|
2375
|
+
snapshotUndo();
|
|
2376
|
+
if (item.kind === 'command') { const slash = state.input.lastIndexOf('/'); state.input = `${state.input.slice(0, slash)}${item.value} `; }
|
|
2377
|
+
else if (item.kind === 'history') { state.input = item.value; }
|
|
2378
|
+
else { const at = state.input.lastIndexOf('@'); state.input = `${state.input.slice(0, at)}@${item.value} `; }
|
|
2379
|
+
state.cursor = state.input.length;
|
|
2380
|
+
state.menu = []; state.menuIndex = -1;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return submit();
|
|
2384
|
+
}
|
|
2385
|
+
if (str && !key.ctrl && !key.meta && str >= ' ') {
|
|
2386
|
+
insert(str);
|
|
2387
|
+
// `:name:` live-replaces the moment the closing colon lands (§3.6).
|
|
2388
|
+
if (str === ':') {
|
|
2389
|
+
const m = /:([A-Za-z0-9_+-]+):$/.exec(state.input.slice(0, state.cursor));
|
|
2390
|
+
if (m && EMOJI[m[1].toLowerCase()]) {
|
|
2391
|
+
state.input = state.input.slice(0, state.cursor - m[0].length) + EMOJI[m[1].toLowerCase()] + state.input.slice(state.cursor);
|
|
2392
|
+
state.cursor += EMOJI[m[1].toLowerCase()].length - m[0].length;
|
|
2393
|
+
updateMenu(); draw();
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
return {
|
|
2399
|
+
state,
|
|
2400
|
+
onEscape(fn) { state.onEscape = fn; }, onRewind(fn) { state.onRewind = fn; }, setMeta(next) { Object.assign(state.meta, next); draw(); },
|
|
2401
|
+
start({ onLine, history = [] }) {
|
|
2402
|
+
state.onLine = onLine; state.history = history.slice(); active = true;
|
|
2403
|
+
// Keys go through readline, but on a filtered stream: raw mouse bytes are
|
|
2404
|
+
// framed out first (see splitMouseSequences) because readline would shred
|
|
2405
|
+
// them into junk keypresses that can never scroll and pollute the prompt.
|
|
2406
|
+
keyFilter = new PassThrough();
|
|
2407
|
+
readline.emitKeypressEvents(keyFilter);
|
|
2408
|
+
let pending = '';
|
|
2409
|
+
let inPaste = false;
|
|
2410
|
+
const PASTE_MAX = 2 * 1024 * 1024;
|
|
2411
|
+
const pumpBytes = (text, force) => {
|
|
2412
|
+
if (!text) return '';
|
|
2413
|
+
const { forward, events, hold } = splitMouseSequences(text);
|
|
2414
|
+
for (const ev of events) {
|
|
2415
|
+
if (ev.focus !== undefined) { onFocus(ev.focus); continue; }
|
|
2416
|
+
if (mouseDisabled()) continue; // terminal owns the mouse; drop ours
|
|
2417
|
+
try { handleMouse(ev); } catch (error) { print(c.red(` ${error?.message || error}`)); }
|
|
2418
|
+
}
|
|
2419
|
+
keyFilter.write(forward + (force ? hold : ''));
|
|
2420
|
+
return force ? '' : hold;
|
|
2421
|
+
};
|
|
2422
|
+
/** A paste over ~800 chars or 3+ lines collapses to a chip (§3.4). */
|
|
2423
|
+
const handlePaste = (content) => {
|
|
2424
|
+
if (!content) return;
|
|
2425
|
+
if (content.length > 800 || content.split('\n').length > 3) insert(`${addPaste(content)} `);
|
|
2426
|
+
else insert(content);
|
|
2427
|
+
};
|
|
2428
|
+
const pump = (force) => {
|
|
2429
|
+
for (;;) {
|
|
2430
|
+
if (!inPaste) {
|
|
2431
|
+
const s = pending.indexOf('\x1b[200~');
|
|
2432
|
+
const head = s === -1 ? pending : pending.slice(0, s);
|
|
2433
|
+
pending = s === -1 ? pumpBytes(head, force) : pending.slice(s + 6);
|
|
2434
|
+
if (s === -1) break;
|
|
2435
|
+
inPaste = true;
|
|
2436
|
+
} else {
|
|
2437
|
+
const e = pending.indexOf('\x1b[201~');
|
|
2438
|
+
if (e === -1) {
|
|
2439
|
+
if (pending.length > PASTE_MAX) { const bulk = pending; pending = ''; inPaste = false; pumpBytes(bulk, true); }
|
|
2440
|
+
break;
|
|
2441
|
+
}
|
|
2442
|
+
const content = pending.slice(0, e);
|
|
2443
|
+
pending = pending.slice(e + 6);
|
|
2444
|
+
inPaste = false;
|
|
2445
|
+
handlePaste(content);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
};
|
|
2449
|
+
const armFlush = () => {
|
|
2450
|
+
if (mouseFlushTimer) clearTimeout(mouseFlushTimer);
|
|
2451
|
+
mouseFlushTimer = setTimeout(() => { mouseFlushTimer = null; pump(true); }, 25);
|
|
2452
|
+
};
|
|
2453
|
+
onRawData = (chunk) => {
|
|
2454
|
+
pending += String(chunk);
|
|
2455
|
+
if (mouseFlushTimer) { clearTimeout(mouseFlushTimer); mouseFlushTimer = null; }
|
|
2456
|
+
pump(false);
|
|
2457
|
+
if (pending) armFlush();
|
|
2458
|
+
};
|
|
2459
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(true); stdin.resume();
|
|
2460
|
+
stdin.on('data', onRawData);
|
|
2461
|
+
// ?1049h alt screen · ?1000h basic + ?1002h button-event (drag) + ?1006h SGR ext
|
|
2462
|
+
// ?1003h any-motion (hover highlight) · ?2004h bracketed paste (§3.4) ·
|
|
2463
|
+
// ?1004 focus reports (§15) · ?7l autowrap OFF (invariant 1) · clear+home.
|
|
2464
|
+
// Mouse modes skipped when capture is off so Shift+drag / native selection
|
|
2465
|
+
// keeps working; hover goes with clicks-only mode.
|
|
2466
|
+
if (mouseDisabled()) rawWrite('\x1b[?1049h\x1b[?2004h\x1b[?1004h\x1b[?7l\x1b[?25l\x1b[2J\x1b[H');
|
|
2467
|
+
else if (mouseClicksDisabled()) rawWrite('\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?1004h\x1b[?7l\x1b[?25l\x1b[2J\x1b[H');
|
|
2468
|
+
else rawWrite('\x1b[?1049h\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1006h\x1b[?2004h\x1b[?1004h\x1b[?7l\x1b[?25l\x1b[2J\x1b[H');
|
|
2469
|
+
onKeypress = (str, key) => { Promise.resolve(handleKey(str, key)).catch((error) => print(c.red(` ${error?.message || error}`))); };
|
|
2470
|
+
keyFilter.on('keypress', onKeypress); onResize = () => draw(); process.stdout.on('resize', onResize); draw();
|
|
2471
|
+
// One-time hint when mouse is captured (names the bypass key).
|
|
2472
|
+
if (!mouseDisabled() && !process.env.JAITUS_HIDE_MOUSE_HINT) {
|
|
2473
|
+
state.statusText = c.dim(` Mouse captured: drag selects (auto-copy) · ${nativeSelectKey()}+drag for native · wheel scrolls`);
|
|
2474
|
+
draw();
|
|
2475
|
+
setTimeout(() => { if (state.statusText?.includes('Mouse captured')) { state.statusText = null; draw(); } }, 3200);
|
|
2476
|
+
}
|
|
2477
|
+
},
|
|
2478
|
+
stop() {
|
|
2479
|
+
active = false; commitLive();
|
|
2480
|
+
if (busyTimer) { clearInterval(busyTimer); busyTimer = null; }
|
|
2481
|
+
if (mouseFlushTimer) { clearTimeout(mouseFlushTimer); mouseFlushTimer = null; }
|
|
2482
|
+
if (onRawData) stdin.removeListener('data', onRawData);
|
|
2483
|
+
if (onKeypress && keyFilter) keyFilter.removeListener('keypress', onKeypress);
|
|
2484
|
+
keyFilter = null; onRawData = null;
|
|
2485
|
+
if (onResize) process.stdout.removeListener('resize', onResize);
|
|
2486
|
+
if (stdin.isTTY && stdin.setRawMode) stdin.setRawMode(false); stdin.pause();
|
|
2487
|
+
rawWrite('\x1b[?7h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[?1004l\x1b[?25h\x1b[?1049l');
|
|
2488
|
+
},
|
|
2489
|
+
print, printStream, flushStream, bellIfAway,
|
|
2490
|
+
animationsOn() { return state.busyAnim && active; },
|
|
2491
|
+
drainAttachments: (line) => takeAttachments(line),
|
|
2492
|
+
clearTranscript() {
|
|
2493
|
+
state.transcript = []; state.liveText = ''; state.liveKind = null;
|
|
2494
|
+
state.liveUnseen = false; state.transcriptOffset = 0; state.newMessages = 0;
|
|
2495
|
+
state.selection = null;
|
|
2496
|
+
draw();
|
|
2497
|
+
},
|
|
2498
|
+
setStatusLine(value) { state.customStatus = String(value || '').split('\n')[0]; draw(); },
|
|
2499
|
+
updatePanel(lines) {
|
|
2500
|
+
if (!state.panel) return;
|
|
2501
|
+
state.panel.lines = Array.isArray(lines) ? lines : String(lines).split('\n');
|
|
2502
|
+
state.panel.offset = Math.max(0, Math.min(state.panel.lines.length, state.panel.offset));
|
|
2503
|
+
draw();
|
|
2504
|
+
},
|
|
2505
|
+
setBusy(value) {
|
|
2506
|
+
state.busy = value;
|
|
2507
|
+
if (!value) {
|
|
2508
|
+
state.statusText = null;
|
|
2509
|
+
if (busyTimer) { clearInterval(busyTimer); busyTimer = null; }
|
|
2510
|
+
} else {
|
|
2511
|
+
// Cache the motion preference once per run, not once per frame.
|
|
2512
|
+
try {
|
|
2513
|
+
state.busyAnim = process.env.JAITUS_NO_ANIMATION ? false : loadSettings().animations !== false;
|
|
2514
|
+
} catch { state.busyAnim = true; }
|
|
2515
|
+
state.busyTick = 0;
|
|
2516
|
+
if (!busyTimer && state.busyAnim) {
|
|
2517
|
+
busyTimer = setInterval(() => {
|
|
2518
|
+
if (!active || !state.busy) { if (busyTimer) { clearInterval(busyTimer); busyTimer = null; } return; }
|
|
2519
|
+
// Explicit tool status already moves; animate the waiting row only.
|
|
2520
|
+
if (state.statusText === null) { state.busyTick += 1; draw(); }
|
|
2521
|
+
}, 120);
|
|
2522
|
+
if (busyTimer.unref) busyTimer.unref();
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
draw();
|
|
2526
|
+
},
|
|
2527
|
+
setStatus(value) { state.statusText = value; draw(); }, clearStatus() { state.statusText = null; draw(); },
|
|
2528
|
+
queue(line) { state.queued.push(line); draw(); }, takeQueued() { const queued = state.queued; state.queued = []; draw(); return queued; }, hasQueued: () => state.queued.length > 0,
|
|
2529
|
+
askDialog(question, options, preview = [], opts = {}) {
|
|
2530
|
+
return new Promise((resolve) => {
|
|
2531
|
+
state.dialog = {
|
|
2532
|
+
question, options, preview, index: 0, resolve, comment: '', editing: false,
|
|
2533
|
+
title: opts.title || null, hideComment: !!opts.hideComment,
|
|
2534
|
+
icon: opts.icon || null, pills: !!opts.pills,
|
|
2535
|
+
};
|
|
2536
|
+
draw();
|
|
2537
|
+
});
|
|
2538
|
+
}, redraw: draw,
|
|
2539
|
+
setHome(on) { state.home = !!on; draw(); },
|
|
2540
|
+
showPanel(title, lines) { return new Promise((resolve) => { state.panel = { title, lines: Array.isArray(lines) ? lines : String(lines).split('\n'), offset: 0, resolve }; draw(); }); },
|
|
2541
|
+
recordActivity(event) {
|
|
2542
|
+
const existing = event.id ? state.activity.find((item) => item.id === event.id) : null;
|
|
2543
|
+
if (existing) Object.assign(existing, event, { updatedAt: Date.now() });
|
|
2544
|
+
else {
|
|
2545
|
+
const item = { id: `${Date.now()}-${state.activity.length}`, name: event.name, label: String(event.label || event.name), status: event.status || 'running', detail: event.detail || '', updatedAt: Date.now() };
|
|
2546
|
+
state.activity.push(item);
|
|
2547
|
+
if (state.activity.length > 100) state.activity.shift();
|
|
2548
|
+
return item.id;
|
|
2549
|
+
}
|
|
2550
|
+
return existing.id;
|
|
2551
|
+
},
|
|
2552
|
+
activitySnapshot() { return state.activity.map((item) => ({ ...item })); },
|
|
2553
|
+
get exited() { return state.shouldExit; }, _handleKey: handleKey, _drawRegion: draw,
|
|
2554
|
+
};
|
|
2555
|
+
}
|