nou-cli 0.1.0 → 0.2.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/README.md +140 -31
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +55 -6
- package/dist/src/agent/index.js +37 -5
- package/dist/src/commands/index.js +16 -6
- package/dist/src/config/index.js +5 -2
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +94 -3
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/ollama.js +2 -1
- package/dist/src/providers/openai.js +3 -2
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/ui/App.js +268 -29
- package/dist/src/ui/Chat.js +11 -4
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +21 -5
- package/dist/src/ui/Editor.js +90 -21
- package/dist/src/ui/FileTree.js +6 -4
- package/dist/src/ui/Header.js +2 -2
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/Settings.js +30 -15
- package/dist/src/ui/text.js +6 -1
- package/dist/src/update/index.js +102 -0
- package/package.json +9 -4
package/dist/src/ui/App.js
CHANGED
|
@@ -8,8 +8,14 @@ import { describe, providers, resolveModel, saveModel } from "../providers/index
|
|
|
8
8
|
import { loadSettings, saveSettings } from "../config/index.js";
|
|
9
9
|
import { branch as gitBranch, diffAgainstHead, isRepo, status as gitStatus, summary as gitSummary } from "../git/index.js";
|
|
10
10
|
import { gitTools } from "../git/tools.js";
|
|
11
|
-
import { discoverPlugins, loadPlugin } from "../plugins/index.js";
|
|
11
|
+
import { BUILTIN_PLUGINS, discoverPlugins, isEnabled, loadPlugin, trustPatch } from "../plugins/index.js";
|
|
12
|
+
import { DiagnosticsManager, formatDiagnostic, newProblems } from "../diagnostics/index.js";
|
|
13
|
+
import { Timeline } from "../history/index.js";
|
|
14
|
+
import { lsp } from "../lsp/index.js";
|
|
15
|
+
import { onGrammarLoaded, preload } from "../syntax/index.js";
|
|
16
|
+
import { checkForUpdate } from "../update/index.js";
|
|
12
17
|
import { changedLines } from "./text.js";
|
|
18
|
+
import { ProblemsView } from "./ProblemsView.js";
|
|
13
19
|
import { C } from "./theme.js";
|
|
14
20
|
import { FileTree } from "./FileTree.js";
|
|
15
21
|
import { CodeView } from "./CodeView.js";
|
|
@@ -26,10 +32,15 @@ const HELP = `Commands:
|
|
|
26
32
|
/model <provider[:model]> switch, e.g. /model anthropic /model ollama:qwen3:8b
|
|
27
33
|
/models [provider] list models a provider offers
|
|
28
34
|
/clear start a fresh conversation
|
|
35
|
+
/undo undo Nou's last change (also: u)
|
|
36
|
+
/rewind undo everything from Nou's last turn
|
|
37
|
+
/history recent changes
|
|
29
38
|
Keys:
|
|
30
39
|
Ctrl+P find a file Tab switch pane Esc stop / back
|
|
31
40
|
e edit open file v diff (git / Nou) f follow mode
|
|
32
|
-
|
|
41
|
+
p problems u undo Nou's edit s settings
|
|
42
|
+
c (in files) show only changed files Ctrl+C twice quit
|
|
43
|
+
In the editor: Ctrl+S save · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G go to definition`;
|
|
33
44
|
function readText(filePath) {
|
|
34
45
|
try {
|
|
35
46
|
return readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
|
|
@@ -65,6 +76,20 @@ export function App({ initialModel, permContext }) {
|
|
|
65
76
|
const [editorState, setEditorState] = useState(null);
|
|
66
77
|
// Unsaved editor text survives switching to a diff and back.
|
|
67
78
|
const draft = useRef(null);
|
|
79
|
+
const [codePanel, setCodePanel] = useState("code");
|
|
80
|
+
const [, setDiagVersion] = useState(0);
|
|
81
|
+
const [syntaxVersion, setSyntaxVersion] = useState(0);
|
|
82
|
+
const [focusLine, setFocusLine] = useState(null);
|
|
83
|
+
const [editorStart, setEditorStart] = useState({ line: 1, nonce: 0 });
|
|
84
|
+
const [pendingNote, setPendingNote] = useState(null);
|
|
85
|
+
const [exitArmed, setExitArmed] = useState(false);
|
|
86
|
+
const [update, setUpdate] = useState(null);
|
|
87
|
+
const diagnosticsRef = useRef(null);
|
|
88
|
+
diagnosticsRef.current ??= new DiagnosticsManager();
|
|
89
|
+
const diagnostics = diagnosticsRef.current;
|
|
90
|
+
const timelineRef = useRef(null);
|
|
91
|
+
timelineRef.current ??= new Timeline();
|
|
92
|
+
const timeline = timelineRef.current;
|
|
68
93
|
const followRef = useRef(follow);
|
|
69
94
|
followRef.current = follow;
|
|
70
95
|
const settingsRef = useRef(settings);
|
|
@@ -78,6 +103,9 @@ export function App({ initialModel, permContext }) {
|
|
|
78
103
|
setContent(readText(filePath));
|
|
79
104
|
setDiffMode("off");
|
|
80
105
|
setEditing(false);
|
|
106
|
+
setCodePanel("code");
|
|
107
|
+
preload(filePath);
|
|
108
|
+
void diagnosticsRef.current.checkFile(filePath);
|
|
81
109
|
}, []);
|
|
82
110
|
const agentRef = useRef(null);
|
|
83
111
|
if (!agentRef.current) {
|
|
@@ -91,7 +119,10 @@ export function App({ initialModel, permContext }) {
|
|
|
91
119
|
open(filePath);
|
|
92
120
|
setReveal(filePath);
|
|
93
121
|
},
|
|
94
|
-
onChange: (change, status) => {
|
|
122
|
+
onChange: (change, status, turn) => {
|
|
123
|
+
if (status === "applied") {
|
|
124
|
+
timelineRef.current.record({ path: change.path, before: change.isNew ? null : change.before, after: change.after, source: "nou", turn });
|
|
125
|
+
}
|
|
95
126
|
const lines = status === "applied" ? changedLines(change.before, change.after) : NO_LINES;
|
|
96
127
|
setChanges((prev) => new Map(prev).set(change.path, { change, status, lines }));
|
|
97
128
|
// Diffs awaiting approval are always shown, follow mode or not.
|
|
@@ -116,17 +147,36 @@ export function App({ initialModel, permContext }) {
|
|
|
116
147
|
}
|
|
117
148
|
return new Promise((resolve) => setApproval({ request, resolve }));
|
|
118
149
|
},
|
|
150
|
+
checkChange: async (change) => {
|
|
151
|
+
const introduced = await diagnosticsRef.current.introducedBy(change.path, change.before, change.after);
|
|
152
|
+
return introduced.length ? introduced.slice(0, 15).map((d) => formatDiagnostic(d)).join("\n") : null;
|
|
153
|
+
},
|
|
119
154
|
});
|
|
120
155
|
}
|
|
156
|
+
useEffect(() => diagnostics.subscribe(() => setDiagVersion((v) => v + 1)), []);
|
|
157
|
+
useEffect(() => onGrammarLoaded(() => setSyntaxVersion((v) => v + 1)), []);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
void checkForUpdate().then(setUpdate);
|
|
160
|
+
}, []);
|
|
121
161
|
// Discover plugins once and load the enabled ones.
|
|
122
162
|
useEffect(() => {
|
|
123
163
|
void (async () => {
|
|
124
164
|
const found = discoverPlugins();
|
|
125
|
-
const
|
|
126
|
-
setPlugins(await Promise.all(found.map((p) => (
|
|
165
|
+
const saved = loadSettings();
|
|
166
|
+
setPlugins(await Promise.all(found.map((p) => (isEnabled(p, saved) ? loadPlugin(p) : p))));
|
|
127
167
|
})();
|
|
128
168
|
}, []);
|
|
129
|
-
const
|
|
169
|
+
const allPlugins = useMemo(() => [...BUILTIN_PLUGINS, ...plugins], [plugins]);
|
|
170
|
+
const activePlugins = useMemo(() => allPlugins.filter((p) => isEnabled(p, settings) && p.plugin), [allPlugins, settings.plugins, settings.trustedPlugins]);
|
|
171
|
+
// Checkers come from enabled plugins (built-in ones included).
|
|
172
|
+
const firstCheck = useRef(true);
|
|
173
|
+
useEffect(() => {
|
|
174
|
+
diagnostics.setProviders(activePlugins.flatMap((p) => p.plugin.diagnostics ?? []));
|
|
175
|
+
if (firstCheck.current) {
|
|
176
|
+
firstCheck.current = false;
|
|
177
|
+
setTimeout(() => void diagnostics.checkProject(), 1500);
|
|
178
|
+
}
|
|
179
|
+
}, [activePlugins]);
|
|
130
180
|
// Keep the agent's system prompt and tools in sync with settings and plugins.
|
|
131
181
|
useEffect(() => {
|
|
132
182
|
const extras = [];
|
|
@@ -149,6 +199,24 @@ export function App({ initialModel, permContext }) {
|
|
|
149
199
|
});
|
|
150
200
|
}
|
|
151
201
|
}
|
|
202
|
+
extraTools.push({
|
|
203
|
+
source: "problems",
|
|
204
|
+
readOnly: true,
|
|
205
|
+
definition: {
|
|
206
|
+
name: "get_diagnostics",
|
|
207
|
+
description: "Get errors and warnings from the project's linters, type checker and language servers, for one file or the whole project.",
|
|
208
|
+
parameters: { type: "object", properties: { path: { type: "string", description: "Optional file. Omit for the whole project." } }, required: [] },
|
|
209
|
+
},
|
|
210
|
+
run: async (args) => {
|
|
211
|
+
const file = typeof args.path === "string" ? args.path : null;
|
|
212
|
+
if (file)
|
|
213
|
+
await diagnostics.checkFile(file);
|
|
214
|
+
else
|
|
215
|
+
await diagnostics.checkProject();
|
|
216
|
+
const list = file ? diagnostics.get(file) : diagnostics.all();
|
|
217
|
+
return list.length ? list.slice(0, 100).map((d) => formatDiagnostic(d, true)).join("\n") : "No problems found.";
|
|
218
|
+
},
|
|
219
|
+
});
|
|
152
220
|
agentRef.current.configure({ system: buildSystemPrompt(permContext, extras), extraTools, effort: settings.effort });
|
|
153
221
|
}, [settings.gitContext, settings.effort, activePlugins]);
|
|
154
222
|
// Pick up changes made outside Nou (other editors, git commands).
|
|
@@ -183,11 +251,13 @@ export function App({ initialModel, permContext }) {
|
|
|
183
251
|
return true;
|
|
184
252
|
};
|
|
185
253
|
const togglePlugin = async (id, enabled) => {
|
|
186
|
-
|
|
254
|
+
const entry = allPlugins.find((p) => p.id === id);
|
|
255
|
+
if (!entry)
|
|
256
|
+
return;
|
|
257
|
+
updateSettings(trustPatch(entry, enabled, settings));
|
|
187
258
|
if (!enabled)
|
|
188
259
|
return;
|
|
189
|
-
|
|
190
|
-
if (entry && !entry.plugin) {
|
|
260
|
+
if (!entry.plugin) {
|
|
191
261
|
const loaded = await loadPlugin(entry);
|
|
192
262
|
setPlugins((prev) => prev.map((p) => (p.id === id ? loaded : p)));
|
|
193
263
|
}
|
|
@@ -201,12 +271,141 @@ export function App({ initialModel, permContext }) {
|
|
|
201
271
|
const gitChange = useMemo(() => (diffMode === "git" && openFile ? diffAgainstHead(openFile, content) : null), [diffMode, openFile, content]);
|
|
202
272
|
const diff = pendingChange ?? (diffMode === "git" ? gitChange : diffMode === "nou" && record ? record.change : null);
|
|
203
273
|
const showEditor = editing && !!openFile && !pendingChange;
|
|
274
|
+
// Check Nou's proposed edit before you approve it: does it add problems?
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
setPendingNote(null);
|
|
277
|
+
if (!pendingChange)
|
|
278
|
+
return;
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
void (async () => {
|
|
281
|
+
const before = await diagnostics.checkText(pendingChange.path, pendingChange.before);
|
|
282
|
+
const after = await diagnostics.checkText(pendingChange.path, pendingChange.after);
|
|
283
|
+
if (cancelled)
|
|
284
|
+
return;
|
|
285
|
+
const added = newProblems(before, after);
|
|
286
|
+
setPendingNote(added.length
|
|
287
|
+
? { short: `⚠ +${added.length} problem${added.length === 1 ? "" : "s"}`, detail: added.slice(0, 3).map((d) => formatDiagnostic(d)).join("\n") }
|
|
288
|
+
: { short: "✓ no new problems", detail: null });
|
|
289
|
+
})();
|
|
290
|
+
return () => {
|
|
291
|
+
cancelled = true;
|
|
292
|
+
};
|
|
293
|
+
}, [pendingChange]);
|
|
294
|
+
// Live problems while you type (debounced).
|
|
295
|
+
useEffect(() => {
|
|
296
|
+
if (!showEditor || !openFile || !editorState)
|
|
297
|
+
return;
|
|
298
|
+
const timer = setTimeout(() => void diagnostics.checkFile(openFile, editorState.lines.join("\n")), 700);
|
|
299
|
+
return () => clearTimeout(timer);
|
|
300
|
+
}, [editorState?.lines]);
|
|
301
|
+
/** Quit, saving the editor's unsaved text first if auto-save is on. */
|
|
302
|
+
const quit = () => {
|
|
303
|
+
if (draft.current && settings.autoSave !== "off")
|
|
304
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
305
|
+
exit();
|
|
306
|
+
};
|
|
307
|
+
/** Quitting always takes two presses (Ctrl+C or q) within 2 seconds, so a stray key never loses work. */
|
|
308
|
+
const requestQuit = () => {
|
|
309
|
+
if (exitArmed)
|
|
310
|
+
return quit();
|
|
311
|
+
setExitArmed(true);
|
|
312
|
+
setTimeout(() => setExitArmed(false), 2000);
|
|
313
|
+
};
|
|
314
|
+
// Ctrl+C: stop Nou if it's working; otherwise press twice to quit.
|
|
315
|
+
useInput((input, key) => {
|
|
316
|
+
if (!(key.ctrl && input === "c"))
|
|
317
|
+
return;
|
|
318
|
+
if (busy || approval) {
|
|
319
|
+
agentRef.current?.cancel();
|
|
320
|
+
approval?.resolve(false);
|
|
321
|
+
setApproval(null);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
requestQuit();
|
|
325
|
+
});
|
|
326
|
+
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
327
|
+
/** After a file changes outside the normal flow (undo), refresh everything that shows it. */
|
|
328
|
+
const reload = (file) => {
|
|
329
|
+
if (file === openFile)
|
|
330
|
+
setContent(readText(file));
|
|
331
|
+
setTreeVersion((v) => v + 1);
|
|
332
|
+
refreshGit();
|
|
333
|
+
void diagnostics.checkFile(file);
|
|
334
|
+
};
|
|
335
|
+
const undoCheckpoints = (list) => {
|
|
336
|
+
const done = [];
|
|
337
|
+
for (const cp of list) {
|
|
338
|
+
const result = timeline.undo(cp);
|
|
339
|
+
if (result.ok) {
|
|
340
|
+
done.push(cp.path);
|
|
341
|
+
info(`↶ Undid Nou's change to ${cp.path}${result.merged ? " (kept your later edits)" : ""}`);
|
|
342
|
+
reload(cp.path);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
info(`Couldn't undo ${cp.path}: ${result.reason}. Press v on the file to see the diff.`, "error");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (done.length)
|
|
349
|
+
agentRef.current.note(`The user undid your changes to ${done.join(", ")}; those files are back to how they were before your edits.`);
|
|
350
|
+
};
|
|
351
|
+
const undoLast = () => {
|
|
352
|
+
const last = timeline.lastNouChange();
|
|
353
|
+
if (!last)
|
|
354
|
+
return info("Nothing of Nou's to undo.");
|
|
355
|
+
undoCheckpoints([last]);
|
|
356
|
+
};
|
|
357
|
+
const rewind = () => {
|
|
358
|
+
const turn = timeline.lastTurn();
|
|
359
|
+
if (!turn.length)
|
|
360
|
+
return info("Nothing of Nou's to rewind.");
|
|
361
|
+
undoCheckpoints(turn);
|
|
362
|
+
};
|
|
363
|
+
const showHistory = () => {
|
|
364
|
+
const items = timeline.all.slice(-15).reverse();
|
|
365
|
+
if (!items.length)
|
|
366
|
+
return info("No changes recorded yet.");
|
|
367
|
+
info(`Recent changes (newest first):\n${items.map((c) => {
|
|
368
|
+
const time = new Date(c.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
369
|
+
return ` ${time} ${c.source === "nou" ? "Nou" : "you"} ${c.before === null ? "created " : ""}${c.path}${c.undone ? " (undone)" : ""}`;
|
|
370
|
+
}).join("\n")}\nUndo with /undo (last) or /rewind (whole last turn).`);
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Before editing another file: an unsaved draft of a different file is saved (auto-save on) or kept and
|
|
374
|
+
* the switch refused (auto-save off). Never silently dropped. Returns whether it's OK to continue.
|
|
375
|
+
*/
|
|
376
|
+
const settleDraft = (target) => {
|
|
377
|
+
if (!draft.current || draft.current.path === target)
|
|
378
|
+
return true;
|
|
379
|
+
if (settings.autoSave !== "off") {
|
|
380
|
+
saveFromEditor(draft.current.text, draft.current.path);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
info(`You have unsaved edits in ${draft.current.path}. Open it and save (Ctrl+S) first, or turn on auto-save in settings.`, "error");
|
|
384
|
+
return false;
|
|
385
|
+
};
|
|
386
|
+
/** Opens a file at a line, in the editor if `edit` (used by the Problems list and go-to-definition). */
|
|
387
|
+
const openAt = (file, line, edit = false) => {
|
|
388
|
+
if (!settleDraft(file))
|
|
389
|
+
return;
|
|
390
|
+
if (file !== openFile)
|
|
391
|
+
open(file);
|
|
392
|
+
setReveal(file);
|
|
393
|
+
setCodePanel("code");
|
|
394
|
+
if (edit) {
|
|
395
|
+
setEditorStart((s) => ({ line, nonce: s.nonce + 1 }));
|
|
396
|
+
setEditing(true);
|
|
397
|
+
setFocus("code");
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
setFocusLine((f) => ({ line, nonce: (f?.nonce ?? 0) + 1 }));
|
|
401
|
+
}
|
|
402
|
+
};
|
|
204
403
|
const startEditing = () => {
|
|
205
|
-
if (!openFile)
|
|
404
|
+
if (!openFile || !settleDraft(openFile))
|
|
206
405
|
return;
|
|
207
|
-
if (draft.current && draft.current.path !== openFile)
|
|
208
|
-
draft.current = null;
|
|
209
406
|
setDiffMode("off");
|
|
407
|
+
setCodePanel("code");
|
|
408
|
+
setEditorStart((s) => ({ line: viewport?.top ?? 1, nonce: s.nonce + 1 }));
|
|
210
409
|
setEditing(true);
|
|
211
410
|
setFocus("code");
|
|
212
411
|
};
|
|
@@ -261,6 +460,13 @@ export function App({ initialModel, permContext }) {
|
|
|
261
460
|
return; // letters belong to the text box
|
|
262
461
|
if (input === "f")
|
|
263
462
|
setFollow((f) => !f);
|
|
463
|
+
else if (input === "p") {
|
|
464
|
+
setCodePanel((p) => (p === "problems" ? "code" : "problems"));
|
|
465
|
+
setEditing(false);
|
|
466
|
+
setFocus("code");
|
|
467
|
+
}
|
|
468
|
+
else if (input === "u")
|
|
469
|
+
undoLast();
|
|
264
470
|
else if (input === "v")
|
|
265
471
|
cycleDiff();
|
|
266
472
|
else if (input === "e")
|
|
@@ -272,8 +478,8 @@ export function App({ initialModel, permContext }) {
|
|
|
272
478
|
setTreeVersion((v) => v + 1);
|
|
273
479
|
}
|
|
274
480
|
else if (input === "q")
|
|
275
|
-
|
|
276
|
-
}, { isActive: view === "main" && !quickOpen });
|
|
481
|
+
requestQuit();
|
|
482
|
+
}, { isActive: view === "main" && !quickOpen && !(codePanel === "problems" && focus === "code") });
|
|
277
483
|
/** What the user is looking at, attached to each message so "this file" / "here" make sense. */
|
|
278
484
|
const editorContext = () => {
|
|
279
485
|
if (!openFile)
|
|
@@ -305,8 +511,17 @@ ${snippet}
|
|
|
305
511
|
const runCommand = async (input) => {
|
|
306
512
|
const [command, ...rest] = input.slice(1).split(/\s+/);
|
|
307
513
|
const arg = rest.join(" ").trim();
|
|
308
|
-
const info = (text, kind = "info") => setEntries((prev) => [...prev, { kind, text }]);
|
|
309
514
|
switch (command) {
|
|
515
|
+
case "undo":
|
|
516
|
+
return undoLast();
|
|
517
|
+
case "rewind":
|
|
518
|
+
return rewind();
|
|
519
|
+
case "history":
|
|
520
|
+
return showHistory();
|
|
521
|
+
case "problems":
|
|
522
|
+
setCodePanel("problems");
|
|
523
|
+
setFocus("code");
|
|
524
|
+
return;
|
|
310
525
|
case "settings":
|
|
311
526
|
setView("settings");
|
|
312
527
|
return;
|
|
@@ -372,22 +587,29 @@ ${snippet}
|
|
|
372
587
|
setEntries((prev) => [...prev, { kind: "user", text }]);
|
|
373
588
|
void agentRef.current.send(text, editorContext() ?? undefined);
|
|
374
589
|
};
|
|
375
|
-
|
|
376
|
-
if (!
|
|
590
|
+
function saveFromEditor(text, file = openFile) {
|
|
591
|
+
if (!file)
|
|
377
592
|
return;
|
|
378
593
|
try {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
594
|
+
const full = path.resolve(process.cwd(), file);
|
|
595
|
+
const before = readText(file);
|
|
596
|
+
writeFileSync(full, text, "utf-8");
|
|
597
|
+
timeline.record({ path: file, before, after: text, source: "you", turn: agentRef.current.turnId });
|
|
598
|
+
if (file === openFile)
|
|
599
|
+
setContent(text);
|
|
600
|
+
if (draft.current?.path === file)
|
|
601
|
+
draft.current = null;
|
|
382
602
|
setTreeVersion((v) => v + 1);
|
|
383
603
|
refreshGit();
|
|
604
|
+
void diagnostics.checkFile(file);
|
|
605
|
+
diagnostics.scheduleProject();
|
|
384
606
|
}
|
|
385
607
|
catch (error) {
|
|
386
|
-
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${
|
|
608
|
+
setEntries((prev) => [...prev, { kind: "error", text: `Couldn't save ${file}: ${error.message}` }]);
|
|
387
609
|
}
|
|
388
|
-
}
|
|
610
|
+
}
|
|
389
611
|
if (view === "settings") {
|
|
390
|
-
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins:
|
|
612
|
+
return (_jsx(Settings, { width: columns, height: rows, settings: settings, choice: choice, plugins: allPlugins, onModel: (next) => switchModel(next), onChange: updateSettings, onTogglePlugin: (id, enabled) => void togglePlugin(id, enabled), onRescanPlugins: () => {
|
|
391
613
|
const found = discoverPlugins();
|
|
392
614
|
setPlugins((prev) => found.map((f) => prev.find((p) => p.id === f.id && p.plugin) ?? f));
|
|
393
615
|
}, onClose: () => setView("main") }));
|
|
@@ -397,6 +619,10 @@ ${snippet}
|
|
|
397
619
|
const chatWidth = Math.max(34, Math.floor(columns * 0.3));
|
|
398
620
|
const codeWidth = Math.max(20, columns - treeWidth - chatWidth);
|
|
399
621
|
const edited = new Set([...changes].filter(([, c]) => c.status === "applied").map(([p]) => p));
|
|
622
|
+
const fileProblems = openFile ? diagnostics.get(openFile) : [];
|
|
623
|
+
const totals = diagnostics.counts();
|
|
624
|
+
const checking = diagnostics.busy;
|
|
625
|
+
const lspEnabled = activePlugins.some((p) => p.id === "lsp");
|
|
400
626
|
const mode = approval
|
|
401
627
|
? { label: "APPROVE", color: C.yellow }
|
|
402
628
|
: showEditor ? { label: "EDIT", color: C.green }
|
|
@@ -406,16 +632,27 @@ ${snippet}
|
|
|
406
632
|
const hints = approval
|
|
407
633
|
? "y accept · n reject · Esc stop"
|
|
408
634
|
: showEditor && focus === "code"
|
|
409
|
-
?
|
|
635
|
+
? `Ctrl+S save${settings.autoSave === "off" ? "" : " (auto-save on)"} · Ctrl+Z/Y undo/redo · Ctrl+K info · Ctrl+G definition · Esc close`
|
|
410
636
|
: focus === "chat"
|
|
411
637
|
? busy ? "Esc stop · Tab switch pane" : "Enter send · / commands · Ctrl+P find file · Tab switch pane"
|
|
412
638
|
: focus === "tree"
|
|
413
|
-
? "↑↓ move · Enter open · c changes · e edit · Ctrl+P find · s settings
|
|
414
|
-
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff ·
|
|
415
|
-
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, onOpen: (filePath) => {
|
|
639
|
+
? "↑↓ move · Enter open · c changes · e edit · p problems · u undo · Ctrl+P find · s settings"
|
|
640
|
+
: diff ? "n/N next/prev change · v cycle diff · Esc close diff" : "↑↓ scroll · e edit · v diff · p problems · u undo · s settings";
|
|
641
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, children: [_jsx(Header, { width: columns, branch: branch, changes: git.size, model: describe(choice), local: choice.provider.local, busy: busy, update: update }), _jsxs(Box, { flexDirection: "row", height: height, children: [_jsx(FileTree, { width: treeWidth, height: height, focused: focus === "tree" && !approval, openFile: openFile, reveal: reveal, version: treeVersion, changed: edited, git: git, problemCounts: diagnostics.fileCounts(), onOpen: (filePath) => {
|
|
416
642
|
open(filePath);
|
|
417
643
|
setFocus("code");
|
|
418
|
-
}, onHover: () => { } }), showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content,
|
|
644
|
+
}, onHover: () => { } }), codePanel === "problems" && !pendingChange ? (_jsx(ProblemsView, { width: codeWidth, height: height, focused: focus === "code" && !approval, problems: diagnostics.all(), checking: checking, onJump: (file, line) => openAt(file, line), onClose: () => setCodePanel("code") })) : showEditor ? (_jsx(Editor, { width: codeWidth, height: height, focused: focus === "code", filePath: openFile, initial: draft.current?.path === openFile ? draft.current.text : content, baseline: draft.current?.path === openFile ? draft.current.baseline : content, startLine: editorStart.line, problems: fileProblems, autoSave: settings.autoSave, syntaxVersion: syntaxVersion, onHover: (text, line, col) => (lspEnabled ? lsp.hover(openFile, text, line, col) : Promise.resolve(null)), onDefinition: (text, line, col) => {
|
|
645
|
+
if (!lspEnabled)
|
|
646
|
+
return info("Go to definition needs a language server (see /settings → Plugins).", "error");
|
|
647
|
+
void lsp.definition(openFile, text, line, col).then((target) => {
|
|
648
|
+
if (!target)
|
|
649
|
+
return info("No definition found here.");
|
|
650
|
+
// Keep unsaved text when jumping within the same file.
|
|
651
|
+
if (target.file === openFile && draft.current)
|
|
652
|
+
draft.current = { ...draft.current, text };
|
|
653
|
+
openAt(target.file, target.line, true);
|
|
654
|
+
});
|
|
655
|
+
}, onSave: (text) => saveFromEditor(text), onExit: () => {
|
|
419
656
|
draft.current = null;
|
|
420
657
|
setEditing(false);
|
|
421
658
|
}, onState: (state) => {
|
|
@@ -423,7 +660,9 @@ ${snippet}
|
|
|
423
660
|
draft.current = state.dirty
|
|
424
661
|
? { path: openFile, text: state.lines.join("\n"), baseline: draft.current?.path === openFile ? draft.current.baseline : content }
|
|
425
662
|
: null;
|
|
426
|
-
} }, openFile)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, contextChip: contextChip, onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }),
|
|
663
|
+
} }, `${openFile}:${editorStart.nonce}`)) : (_jsx(CodeView, { width: codeWidth, height: height, focused: focus === "code" && !approval, filePath: openFile, content: content, diff: diff, diffLabel: diffMode === "git" ? "vs HEAD" : "Nou's edit", pending: !!pendingChange, marked: record?.lines ?? NO_LINES, problems: fileProblems, focusLine: focusLine, syntaxVersion: syntaxVersion, diffNote: pendingChange ? pendingNote?.short ?? (checking.length ? "checking…" : null) : null, onViewport: (top, bottom) => setViewport((v) => (v?.top === top && v.bottom === bottom ? v : { top, bottom })) })), _jsx(Chat, { width: chatWidth, height: height, focused: focus === "chat", entries: entries, streaming: streaming, busy: busy, approval: approval?.request ?? null, approvalNote: pendingChange ? pendingNote?.detail ?? null : null, contextChip: contextChip, onSubmit: send })] }), _jsx(Box, { children: _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { backgroundColor: mode.color, color: "black", bold: true, children: ` ${mode.label} ` }), _jsx(Text, { backgroundColor: C.panel, color: follow ? C.green : C.dim, children: follow ? " ◉ follow " : " ○ follow " }), _jsx(Text, { backgroundColor: C.panel, color: totals.errors ? C.red : C.dim, children: ` ✖ ${totals.errors}` }), _jsx(Text, { backgroundColor: C.panel, color: totals.warnings ? C.yellow : C.dim, children: ` ▲ ${totals.warnings} ` }), checking.length > 0 && _jsxs(Text, { color: C.accent2, children: [" \u27F3 ", checking.join(", ")] }), exitArmed
|
|
664
|
+
? _jsxs(Text, { color: C.yellow, bold: true, children: [" ", "Press again to quit", draft.current && settings.autoSave === "off" ? ` (unsaved changes in ${draft.current.path} will be lost)` : ""] })
|
|
665
|
+
: _jsxs(Text, { color: C.dim, children: [" ", hints] })] }) }), quickOpen && (_jsx(QuickOpen, { width: columns, top: 3, files: allFiles, onPick: (file) => {
|
|
427
666
|
setQuickOpen(false);
|
|
428
667
|
open(file);
|
|
429
668
|
setReveal(file);
|
package/dist/src/ui/Chat.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
3
|
import { Box, Text, useAnimation, useInput } from "ink";
|
|
4
|
-
import TextInput from "ink-text-input";
|
|
5
4
|
import { wrap } from "./text.js";
|
|
5
|
+
import { ChatInput, inputHeight } from "./ChatInput.js";
|
|
6
6
|
import { C, SPINNER } from "./theme.js";
|
|
7
7
|
import { Pane } from "./Pane.js";
|
|
8
8
|
export const COMMANDS = ["/model", "/models", "/settings", "/clear", "/help"];
|
|
9
|
-
export function Chat({ width, height, focused, entries, streaming, busy, approval, contextChip, onSubmit }) {
|
|
9
|
+
export function Chat({ width, height, focused, entries, streaming, busy, approval, approvalNote, contextChip, onSubmit }) {
|
|
10
10
|
const [input, setInput] = useState("");
|
|
11
11
|
const [scrollBack, setScrollBack] = useState(0);
|
|
12
12
|
const { frame } = useAnimation({ interval: 80, isActive: !!busy });
|
|
@@ -55,6 +55,11 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
55
55
|
}
|
|
56
56
|
else if (approval?.kind === "change") {
|
|
57
57
|
footer.push({ text: `✎ Review ${approval.change.path} →`, color: C.yellow, bold: true });
|
|
58
|
+
if (approvalNote) {
|
|
59
|
+
footer.push({ text: "⚠ This edit adds problems:", color: C.red, bold: true });
|
|
60
|
+
for (const line of wrap(approvalNote, inner - 2).slice(0, 6))
|
|
61
|
+
footer.push({ text: ` ${line}`, color: C.red });
|
|
62
|
+
}
|
|
58
63
|
footer.push({ text: " [y] accept [n] reject [Esc] stop", color: C.yellow });
|
|
59
64
|
}
|
|
60
65
|
else if (busy) {
|
|
@@ -63,7 +68,9 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
63
68
|
const suggestions = input.startsWith("/") && !input.includes(" ")
|
|
64
69
|
? COMMANDS.filter((c) => c.startsWith(input) && c !== input)
|
|
65
70
|
: [];
|
|
66
|
-
const
|
|
71
|
+
const inputRows = inputHeight(input, inner);
|
|
72
|
+
const rows = Math.max(1, height - 3 - footer.length - 2 - inputRows);
|
|
73
|
+
const history = useMemo(() => entries.filter((e) => e.kind === "user").map((e) => e.text), [entries]);
|
|
67
74
|
const maxBack = Math.max(0, lines.length - rows);
|
|
68
75
|
const back = Math.min(scrollBack, maxBack);
|
|
69
76
|
const visible = lines.slice(Math.max(0, lines.length - rows - back), lines.length - back);
|
|
@@ -86,5 +93,5 @@ export function Chat({ width, height, focused, entries, streaming, busy, approva
|
|
|
86
93
|
const title = _jsxs(_Fragment, { children: [" ", _jsx(Text, { color: C.accent2, children: "\u25C6" }), " Chat", back ? _jsxs(Text, { color: C.dim, children: [" \u2191", back, " more"] }) : null] });
|
|
87
94
|
return (_jsxs(Pane, { title: title, width: width, height: height, focused: focused, children: [_jsx(Box, { flexDirection: "column", height: rows, children: empty ? (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsx(Text, { color: C.dim, children: "Try:" }), _jsx(Text, { color: C.text, children: " \u201Cexplain this file\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cwhat changed since my last commit?\u201D" }), _jsx(Text, { color: C.text, children: " \u201Cadd error handling here\u201D" }), _jsx(Text, { children: " " }), _jsx(Text, { color: C.dim, children: "I can see the file you have open." })] })) : visible.map((line, i) => render(line, i)) }), _jsx(Box, { flexDirection: "column", children: footer.map((line, i) => render(line, `f${i}`)) }), _jsx(Text, { wrap: "truncate-end", color: C.dim, children: suggestions.length
|
|
88
95
|
? _jsxs(Text, { color: C.accent, children: [suggestions.join(" "), " ", _jsx(Text, { color: C.dim, children: suggestions.length === 1 ? "Enter ↵" : "" })] })
|
|
89
|
-
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }),
|
|
96
|
+
: contextChip ? _jsxs(_Fragment, { children: [_jsx(Text, { color: C.cyan, children: "@" }), " ", contextChip] }) : " " }), _jsx(Text, { color: focused ? C.accent : C.dim, children: "─".repeat(Math.max(0, inner)) }), _jsx(ChatInput, { value: input, onChange: setInput, onSubmit: submit, focus: focused && !approval, width: inner, history: history, placeholder: busy ? "working… Esc to stop" : "Ask anything · / for commands · ↑ history" })] }));
|
|
90
97
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, usePaste } from "ink";
|
|
4
|
+
import { C } from "./theme.js";
|
|
5
|
+
/** Word-wraps `text` into segments of at most `width` characters, honoring newlines. */
|
|
6
|
+
export function layout(text, width) {
|
|
7
|
+
const w = Math.max(1, width);
|
|
8
|
+
const segments = [];
|
|
9
|
+
let pos = 0;
|
|
10
|
+
while (pos <= text.length) {
|
|
11
|
+
const newline = text.indexOf("\n", pos);
|
|
12
|
+
const lineEnd = newline === -1 ? text.length : newline;
|
|
13
|
+
if (lineEnd - pos <= w) {
|
|
14
|
+
segments.push({ start: pos, end: lineEnd });
|
|
15
|
+
if (newline === -1)
|
|
16
|
+
break;
|
|
17
|
+
pos = newline + 1;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
// Break after the last space that fits, or hard-break a long word.
|
|
21
|
+
const space = text.lastIndexOf(" ", pos + w - 1);
|
|
22
|
+
const end = space >= pos ? space + 1 : pos + w;
|
|
23
|
+
segments.push({ start: pos, end });
|
|
24
|
+
pos = end;
|
|
25
|
+
}
|
|
26
|
+
return segments;
|
|
27
|
+
}
|
|
28
|
+
/** The segment the cursor sits in; a cursor at a wrap boundary belongs to the next line. */
|
|
29
|
+
function cursorLine(segments, cursor) {
|
|
30
|
+
for (let i = 0; i < segments.length; i++) {
|
|
31
|
+
const s = segments[i];
|
|
32
|
+
const next = segments[i + 1];
|
|
33
|
+
if (cursor >= s.start && (cursor < s.end || (cursor === s.end && (!next || next.start > s.end))))
|
|
34
|
+
return i;
|
|
35
|
+
}
|
|
36
|
+
return segments.length - 1;
|
|
37
|
+
}
|
|
38
|
+
export function ChatInput({ value, onChange, onSubmit, focus, width, placeholder, history, maxLines = 6 }) {
|
|
39
|
+
const [cursor, setCursor] = useState(value.length);
|
|
40
|
+
const [recall, setRecall] = useState(null);
|
|
41
|
+
const stash = useRef("");
|
|
42
|
+
const textWidth = Math.max(4, width - 2);
|
|
43
|
+
const pos = Math.min(cursor, value.length);
|
|
44
|
+
const segments = layout(value, textWidth);
|
|
45
|
+
const line = cursorLine(segments, pos);
|
|
46
|
+
const set = (next, nextCursor) => {
|
|
47
|
+
onChange(next);
|
|
48
|
+
setCursor(Math.max(0, Math.min(next.length, nextCursor)));
|
|
49
|
+
};
|
|
50
|
+
const insert = (text) => set(value.slice(0, pos) + text + value.slice(pos), pos + text.length);
|
|
51
|
+
const recallHistory = (step) => {
|
|
52
|
+
if (!history.length)
|
|
53
|
+
return false;
|
|
54
|
+
const current = recall ?? history.length;
|
|
55
|
+
const next = current + step;
|
|
56
|
+
if (next < 0 || next > history.length)
|
|
57
|
+
return false;
|
|
58
|
+
if (recall === null)
|
|
59
|
+
stash.current = value;
|
|
60
|
+
const text = next === history.length ? stash.current : history[next];
|
|
61
|
+
setRecall(next === history.length ? null : next);
|
|
62
|
+
set(text, text.length);
|
|
63
|
+
return true;
|
|
64
|
+
};
|
|
65
|
+
usePaste((text) => insert(text.replace(/\r\n?/g, "\n")), { isActive: focus });
|
|
66
|
+
useInput((input, key) => {
|
|
67
|
+
if (key.tab || key.escape || key.pageUp || key.pageDown || (key.shift && (key.upArrow || key.downArrow)))
|
|
68
|
+
return;
|
|
69
|
+
if (key.return) {
|
|
70
|
+
setRecall(null);
|
|
71
|
+
onSubmit(value);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (key.backspace)
|
|
75
|
+
return pos > 0 && set(value.slice(0, pos - 1) + value.slice(pos), pos - 1);
|
|
76
|
+
if (key.delete)
|
|
77
|
+
return pos < value.length && set(value.slice(0, pos) + value.slice(pos + 1), pos);
|
|
78
|
+
if (key.leftArrow)
|
|
79
|
+
return setCursor(key.ctrl || key.meta ? value.lastIndexOf(" ", pos - 2) + 1 : pos - 1 < 0 ? 0 : pos - 1);
|
|
80
|
+
if (key.rightArrow) {
|
|
81
|
+
if (key.ctrl || key.meta) {
|
|
82
|
+
const next = value.indexOf(" ", pos + 1);
|
|
83
|
+
return setCursor(next === -1 ? value.length : next);
|
|
84
|
+
}
|
|
85
|
+
return setCursor(Math.min(value.length, pos + 1));
|
|
86
|
+
}
|
|
87
|
+
if (key.home || (key.ctrl && input === "a"))
|
|
88
|
+
return setCursor(segments[line].start);
|
|
89
|
+
if (key.end || (key.ctrl && input === "e"))
|
|
90
|
+
return setCursor(segments[line].end);
|
|
91
|
+
if (key.ctrl && input === "u")
|
|
92
|
+
return set("", 0);
|
|
93
|
+
if (key.upArrow || key.downArrow) {
|
|
94
|
+
const target = line + (key.upArrow ? -1 : 1);
|
|
95
|
+
if (target >= 0 && target < segments.length) {
|
|
96
|
+
// Move between wrapped lines, keeping the column.
|
|
97
|
+
const col = pos - segments[line].start;
|
|
98
|
+
const s = segments[target];
|
|
99
|
+
return setCursor(Math.min(s.start + col, s.end));
|
|
100
|
+
}
|
|
101
|
+
recallHistory(key.upArrow ? -1 : 1);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (key.ctrl || key.meta || !input)
|
|
105
|
+
return;
|
|
106
|
+
insert(input.replace(/\r\n?/g, "\n"));
|
|
107
|
+
}, { isActive: focus });
|
|
108
|
+
// Show a window of lines around the cursor once the message gets long.
|
|
109
|
+
const first = Math.max(0, Math.min(line - maxLines + 1, segments.length - maxLines));
|
|
110
|
+
const shown = segments.slice(first, first + maxLines);
|
|
111
|
+
const prompt = (i) => (_jsx(Text, { color: focus ? C.accent2 : C.dim, bold: true, children: first + i === 0 ? "❯ " : first > 0 && i === 0 ? "↑ " : " " }));
|
|
112
|
+
if (!value) {
|
|
113
|
+
return (_jsxs(Box, { children: [prompt(0), focus ? _jsx(Text, { inverse: true, children: " " }) : null, _jsx(Text, { color: C.dim, wrap: "truncate-end", children: placeholder })] }));
|
|
114
|
+
}
|
|
115
|
+
return (_jsx(Box, { flexDirection: "column", children: shown.map((s, i) => {
|
|
116
|
+
const text = value.slice(s.start, s.end).replace(/\n/g, "");
|
|
117
|
+
const isCursor = focus && first + i === line;
|
|
118
|
+
if (!isCursor) {
|
|
119
|
+
return _jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text || " " })] }, first + i);
|
|
120
|
+
}
|
|
121
|
+
const at = pos - s.start;
|
|
122
|
+
return (_jsxs(Text, { children: [prompt(i), _jsx(Text, { color: C.text, children: text.slice(0, at) }), _jsx(Text, { inverse: true, children: text[at] ?? " " }), _jsx(Text, { color: C.text, children: text.slice(at + 1) })] }, first + i));
|
|
123
|
+
}) }));
|
|
124
|
+
}
|
|
125
|
+
/** How many rows the input will take, so the chat can size its history above it. */
|
|
126
|
+
export function inputHeight(value, width, maxLines = 6) {
|
|
127
|
+
return value ? Math.min(maxLines, layout(value, Math.max(4, width - 2)).length) : 1;
|
|
128
|
+
}
|