zames_pro 2.89.0 → 2.90.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +32 -585
  3. package/dist/agent-loop.js +47 -4
  4. package/dist/ask-retry.js +49 -0
  5. package/dist/ask-state.js +80 -0
  6. package/dist/browser-base.js +69 -0
  7. package/dist/browser.js +170 -41
  8. package/dist/command-registry.js +58 -0
  9. package/dist/commands.js +25 -2
  10. package/dist/completion-check.js +77 -0
  11. package/dist/config-schema.js +8 -0
  12. package/dist/config.js +14 -0
  13. package/dist/dev-commands.js +27 -0
  14. package/dist/dev-watch.js +96 -0
  15. package/dist/first-run.js +80 -0
  16. package/dist/glm-browser.js +133 -32
  17. package/dist/i18n/common.js +6 -3
  18. package/dist/i18n/config.js +16 -0
  19. package/dist/i18n/wizard.js +25 -0
  20. package/dist/i18n.js +6 -1
  21. package/dist/index.js +1289 -919
  22. package/dist/info-commands.js +61 -0
  23. package/dist/input/layout.js +38 -1
  24. package/dist/input.js +2 -2
  25. package/dist/password-store.js +48 -0
  26. package/dist/prompt-budget.js +23 -0
  27. package/dist/provider-errors.js +53 -0
  28. package/dist/provider-fallback.js +46 -0
  29. package/dist/qwen-browser.js +155 -39
  30. package/dist/run-task.js +2 -1
  31. package/dist/scheduler-commands.js +41 -0
  32. package/dist/send-slot.js +36 -0
  33. package/dist/session-commands.js +42 -0
  34. package/dist/spinner.js +3 -3
  35. package/dist/swallow.js +45 -0
  36. package/dist/system-prompt.js +2 -2
  37. package/dist/tree-commands.js +36 -0
  38. package/dist/tui/FullScreenApp.js +13 -4
  39. package/dist/tui/InputLine.js +20 -3
  40. package/dist/tui/LogView.js +11 -5
  41. package/dist/tui/StatusBar.js +4 -1
  42. package/dist/tui/TuiApp.js +11 -2
  43. package/dist/tui/layout.js +14 -1
  44. package/dist/tui/store.js +2 -1
  45. package/dist/tui/ui-colors.js +2 -0
  46. package/dist/width.js +10 -5
  47. package/llms-full.txt +16 -292
  48. package/package.json +2 -2
@@ -0,0 +1,45 @@
1
+ // N7: ACCOUNTING for swallowed exceptions instead of silent `catch {}`.
2
+ //
3
+ // The browser classes have ~180 empty catches (a selector probe, a best-effort
4
+ // cleanup, an optional DOM read). When a site redesign breaks a selector, such
5
+ // a catch turns the failure into silence: the UI health probe is clean, yet the
6
+ // answer never appears. This helper records the failure by TAG (always) and
7
+ // prints it only in debug mode, so the counters can be surfaced by /doctor and
8
+ // the send telemetry without flooding the terminal.
9
+ //
10
+ // A genuinely expected empty catch (best-effort cleanup, an absent optional
11
+ // element) keeps `swallowSilently()` with a WHY comment — the point is that
12
+ // silence becomes an EXPLICIT choice, not the default.
13
+ const counts = new Map();
14
+ /** Record a swallowed failure. `detail` is only used in debug mode. */
15
+ export function noteSwallowed(tag, err, debug = false) {
16
+ counts.set(tag, (counts.get(tag) ?? 0) + 1);
17
+ if (debug) {
18
+ const msg = err instanceof Error ? err.message : String(err ?? '');
19
+ console.error('[swallowed] ' + tag + (msg ? ': ' + msg : ''));
20
+ }
21
+ }
22
+ /**
23
+ * Run `fn`, swallowing (and counting) any error under `tag`.
24
+ * Returns `fallback` when it failed, so the caller keeps its happy path.
25
+ */
26
+ export async function swallow(tag, fn, fallback, debug = false) {
27
+ try {
28
+ return await fn();
29
+ }
30
+ catch (e) {
31
+ noteSwallowed(tag, e, debug);
32
+ return fallback;
33
+ }
34
+ }
35
+ /** The swallowed-failure counters, sorted by count (for /doctor). */
36
+ export function swallowedReport(limit = 5) {
37
+ return [...counts.entries()]
38
+ .map(([tag, count]) => ({ tag, count }))
39
+ .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
40
+ .slice(0, limit);
41
+ }
42
+ /** Reset the counters (tests and a fresh session). */
43
+ export function resetSwallowed() {
44
+ counts.clear();
45
+ }
@@ -1,5 +1,5 @@
1
1
  import path from 'path';
2
- import { translate } from './i18n.js';
2
+ import { translate, DEFAULT_LOCALE } from './i18n.js';
3
3
  // The dev-mode BACKLOG instruction. English (agent-facing) like the rest of the
4
4
  // prompt. Deliberately narrow: one short note at most, and only when the idea
5
5
  // is OUTSIDE the current task — otherwise the model documents trivia instead
@@ -204,7 +204,7 @@ function renderToolSection(tools) {
204
204
  .map((t2) => `### ${t2.name}\n${t2.description}\nParameters: ${JSON.stringify(t2.parameters)}`)
205
205
  .join('\n\n');
206
206
  }
207
- export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, selfImprovement = false, provider, }) {
207
+ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = DEFAULT_LOCALE, attachments = [], context = null, selfImprovement = false, provider, }) {
208
208
  const t = translate(locale);
209
209
  const toolDescriptions = renderToolSection(tools);
210
210
  // Emit the subagent guidance ONLY when the Task tool is actually in the set
@@ -0,0 +1,36 @@
1
+ // N2 group (c): the git / undo / rewind commands — /diff, /diffstat, /undo,
2
+ // /undo-list (/history), /rewind-list (/checkpoints), /rewind. All of them
3
+ // drive live stores (git, the undo store, the checkpoint store) and the editor
4
+ // lock, so the work is INJECTED; the registry owns the names, aliases and the
5
+ // argument forwarding.
6
+ /** True when the typed line carries the `--staged` flag (any position). */
7
+ export function hasStagedFlag(line) {
8
+ return /(^|\s)--staged(\s|$)/.test(line);
9
+ }
10
+ export function createTreeCommands(deps) {
11
+ return [
12
+ {
13
+ name: 'diff',
14
+ handler: (ctx) => deps.diff(hasStagedFlag(ctx.input)),
15
+ },
16
+ {
17
+ name: 'diffstat',
18
+ handler: (ctx) => deps.diffstat(hasStagedFlag(ctx.input)),
19
+ },
20
+ { name: 'undo', handler: () => deps.undo() },
21
+ {
22
+ name: 'undo-list',
23
+ aliases: ['history'],
24
+ handler: () => deps.undoList(),
25
+ },
26
+ {
27
+ name: 'rewind-list',
28
+ aliases: ['checkpoints'],
29
+ handler: () => deps.rewindList(),
30
+ },
31
+ {
32
+ name: 'rewind',
33
+ handler: (ctx) => deps.rewind(ctx.rest.split(/\s+/)[0] || '1', ctx.input),
34
+ },
35
+ ];
36
+ }
@@ -17,14 +17,17 @@ import { StatusBar } from './StatusBar.js';
17
17
  import { InputLine } from './InputLine.js';
18
18
  import { translate } from '../i18n.js';
19
19
  import { suggestRowCount } from '../input/suggest.js';
20
- import { truncateToWidth, visLen } from '../input/layout.js';
20
+ import { truncateToWidth, visLen, screenGutter, screenInnerCols, } from '../input/layout.js';
21
21
  import { fullScreenLayout } from './layout.js';
22
22
  import { useMouseWheel } from './useMouseWheel.js';
23
23
  import { UI, inputBorderCols } from './ui-colors.js';
24
24
  /** The header bar: title, version, working directory. */
25
25
  function Header({ store }) {
26
26
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
27
- const cols = Math.max(1, useWindowSize().columns || 80);
27
+ // The header lives INSIDE the gutter, so it must lay out to the pane width:
28
+ // filling the terminal width would wrap the title row and add a row the
29
+ // layout does not reserve.
30
+ const cols = screenInnerCols(useWindowSize().columns || 80);
28
31
  const right = [state.provider && '[' + state.provider + ']']
29
32
  .filter(Boolean)
30
33
  .join(' ');
@@ -57,7 +60,13 @@ export function FullScreenApp({ store, inputProps, }) {
57
60
  // caret row can never drift apart (the component used to compute them
58
61
  // separately and the caret landed below the input box when the panel filled
59
62
  // the terminal).
60
- const cols = Math.max(1, useWindowSize().columns || 80);
63
+ // Gutter on both sides: the operator asked for breathing room instead of a
64
+ // UI pinned to the terminal edges (most visible on a wide screen, where the
65
+ // theme's background made the flush layout look broken). Everything below
66
+ // (header, log wrap, status, input box, hint) uses the SAME pane width.
67
+ const termCols = Math.max(1, useWindowSize().columns || 80);
68
+ const gutter = screenGutter(termCols);
69
+ const cols = screenInnerCols(termCols);
61
70
  const fsLayout = fullScreenLayout({
62
71
  termRows,
63
72
  cols,
@@ -112,7 +121,7 @@ export function FullScreenApp({ store, inputProps, }) {
112
121
  const next = cur + delta * 3;
113
122
  store.set({ scroll: Math.max(0, Math.min(maxScroll, next)) });
114
123
  }, state.mouseScroll);
115
- return (_jsxs(Box, { flexDirection: "column", height: termRows, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: UI.dim, children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
124
+ return (_jsxs(Box, { flexDirection: "column", height: termRows, paddingLeft: gutter, paddingRight: gutter, children: [_jsx(Header, { store: store }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 1, overflow: "hidden", justifyContent: "flex-end", children: all.length === 0 ? (_jsx(Text, { color: UI.dim, children: translate(state.locale)('editor.empty_hint') })) : (_jsx(LogList, { entries: visible })) }), _jsx(StatusBar, { state: state, divider: false }), _jsx(InputLine, { ...inputProps, store: store,
116
125
  // Cursor rows above the input box: header + log viewport + status.
117
126
  // The log pane is FLEXIBLE now, so the panel below it starts right
118
127
  // after everything above: header + as many log rows as fit + the
@@ -12,10 +12,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
12
12
  import { Box, Text, useInput, usePaste, useCursor, useWindowSize, useStdin, } from 'ink';
13
13
  import { translate } from '../i18n.js';
14
14
  import { expandPastes, inputInnerCols, layoutInput, SUGGEST_PAGE, } from '../input/layout.js';
15
+ import { screenInnerCols, screenGutter } from '../input/layout.js';
15
16
  import { insertText, insertPaste, insertNewline, backspace, del, moveLeft, moveRight, moveHome, moveEnd, wordLeft, wordRight, deleteWordLeft, deleteWordRight, killToLineEnd, moveVisualRows, pushUndo, undoEdit, historyUp, historyDown, searchStart, searchNext, searchAccept, searchCancel, searchInput, searchBackspace, recordHistory, afterSubmit, rememberDraft, } from './edit.js';
16
17
  import { dispatchKey, parseCsiU } from './keys.js';
17
18
  import { useSyncExternalStore } from 'react';
18
19
  import { matchSlashCommands, suggestWindow, completeSlashCommand, } from '../input/suggest.js';
20
+ import { suggestRowCount } from '../input/suggest.js';
19
21
  import { UI, inputBorderStyle } from './ui-colors.js';
20
22
  /** Render the visual rows of the buffer with the prompt on the first row. */
21
23
  function InputRows({ prompt, buf, cols, }) {
@@ -23,8 +25,10 @@ function InputRows({ prompt, buf, cols, }) {
23
25
  return (_jsx(Box, { flexDirection: "column", children: lay.rows.map((r, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.accent, bold: true, children: r.prefix }), _jsx(Text, { color: UI.text, children: r.text })] }, i))) }));
24
26
  }
25
27
  export function InputLine(props) {
28
+ // Pane width (gutter excluded): the box must not be wider than the padded
29
+ // root, or it overflows the row and the caret math drifts.
26
30
  const { columns } = useWindowSize();
27
- const cols = columns || 80;
31
+ const cols = screenInnerCols(columns || 80);
28
32
  const { setCursorPosition } = useCursor();
29
33
  const [suggestSelected, setSuggestSelected] = useState(-1);
30
34
  // The visible page of the suggestion list follows the SELECTION: with more
@@ -317,8 +321,21 @@ export function InputLine(props) {
317
321
  // code re-measured `rowText` up to `cursorCol` code points, which
318
322
  // DOUBLE-counted wide chars: a CJK buffer put the caret at the line end
319
323
  // instead of under the cursor.
320
- 2 + lay.cursorCol;
321
- setCursorPosition({ x: cursorX, y: (props.cursorTop ?? 3) + cursorRow });
324
+ // 2 (box border + inner padding) + the ROOT gutter (paddingLeft of the
325
+ // full-screen/inline root) + the column inside the box. The gutter was
326
+ // forgotten: at 170 columns the root pads 2, so the caret sat exactly 2
327
+ // cells LEFT of the text the operator typed.
328
+ screenGutter(columns || 80) + 2 + lay.cursorCol;
329
+ // The suggestion popup is drawn ABOVE the box in THIS component, so it pushes
330
+ // the input rows DOWN by its own height. `cursorTop` (from the app) counts
331
+ // the rows above the INPUT BOX, not above the popup, so the popup rows are
332
+ // added HERE — exactly where they are rendered. (A caller that folded them
333
+ // in as well double-counted them and the caret landed inside the popup.)
334
+ const popupRows = suggestions.length > 0 ? suggestRowCount(st.buf, props.commands, cols) : 0;
335
+ setCursorPosition({
336
+ x: cursorX,
337
+ y: (props.cursorTop ?? 3) + popupRows + cursorRow,
338
+ });
322
339
  const { shown, moreCount } = suggestWindow(suggestions, suggestOffset);
323
340
  const t = translate(props.locale);
324
341
  return (_jsxs(Box, { flexDirection: "column", children: [suggestions.length > 0 && (_jsxs(Box, { flexDirection: "column", children: [shown.map((c, i) => {
@@ -1,6 +1,7 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Static, Box, Text } from 'ink';
3
3
  import { theme } from '../theme.js';
4
+ import { UI } from './ui-colors.js';
4
5
  /** One log entry. `raw` items already carry ANSI colors and are printed
5
6
  * verbatim; structured items get a themed prefix. */
6
7
  function LogRow({ entry }) {
@@ -10,10 +11,15 @@ function LogRow({ entry }) {
10
11
  return _jsx(Text, { children: entry.text });
11
12
  }
12
13
  if (entry.bar) {
13
- // An operator turn: `▌` on EVERY visual row. Applying the bar to the whole
14
- // (already wrapped) block keeps the indent equal on continuation lines —
15
- // prefixing only the first line made a multi-line message look ragged.
16
- return _jsx(Text, { children: theme.userBar('▌ ') + theme.user(entry.text) });
14
+ // An operator turn: `▌` on EVERY visual row.
15
+ //
16
+ // A SINGLE <Text> with a colored prefix child is NOT enough: Ink renders a
17
+ // multi-line string as-is, so the prefix appears on the FIRST line only
18
+ // (the old comment claimed the opposite and the bug survived for that
19
+ // reason). The text is already hard-wrapped by the caller
20
+ // (wrapToWidth), so split it back into rows and draw the bar on each one.
21
+ const rows = String(entry.text ?? '').split(String.fromCharCode(10));
22
+ return (_jsx(Box, { flexDirection: "column", children: rows.map((row, i) => (_jsxs(Text, { children: [_jsx(Text, { color: UI.userBar, children: "\u258C " }), _jsx(Text, { color: UI.user, children: row })] }, i))) }));
17
23
  }
18
24
  switch (entry.kind) {
19
25
  case 'user':
@@ -1,6 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useAnimation, useWindowSize } from 'ink';
3
3
  import { translate } from '../i18n.js';
4
+ import { screenInnerCols } from '../input/layout.js';
4
5
  import { DOTS, stripEllipsis } from '../spinner.js';
5
6
  import { UI } from './ui-colors.js';
6
7
  import { truncateToWidth, visLen } from '../input/layout.js';
@@ -175,7 +176,9 @@ export function StatusBar({ state, divider = true, }) {
175
176
  // row sat empty; measuring the badges (they are DROPPED whole when the row
176
177
  // is tight, the context counter survives longest) is both accurate and
177
178
  // keeps the layout stable.
178
- const cols = useWindowSize().columns || 80;
179
+ // Pane width: the full-screen/inline root keeps a gutter on both sides, so
180
+ // the divider must not run to the terminal edge.
181
+ const cols = screenInnerCols(useWindowSize().columns || 80);
179
182
  const badgeW = badgeTextWidth(state);
180
183
  const gap = badgeW > 0 ? 2 : 0;
181
184
  const leftBudget = Math.max(0, cols - 2 - badgeW - gap);
@@ -14,6 +14,7 @@ import { LogView } from './LogView.js';
14
14
  import { StatusBar } from './StatusBar.js';
15
15
  import { InputLine } from './InputLine.js';
16
16
  import { suggestRowCount } from '../input/suggest.js';
17
+ import { screenGutter, screenInnerCols } from '../input/layout.js';
17
18
  export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
18
19
  const state = useSyncExternalStore(store.subscribe, store.getSnapshot);
19
20
  // The watermark is fixed for the lifetime of this mount.
@@ -23,7 +24,15 @@ export function TuiApp({ store, inputProps, initialPrintedCount = 0, }) {
23
24
  // + the suggestion popup (which renders above the box and would otherwise
24
25
  // shift the caret down by its row count). Without the suggestRows term the
25
26
  // caret drifted whenever the «/» list opened.
26
- const cols = useWindowSize().columns || 80;
27
+ // The INLINE layout needs the same gutter the full-screen one has: the
28
+ // operator's complaint was that switching a theme made the flush edges
29
+ // obvious (the themed background only covers what we paint). The width fed
30
+ // to the suggest-row math must match the padded pane, or the caret drifts.
31
+ const gutter = screenGutter(useWindowSize().columns || 80);
32
+ const cols = screenInnerCols(useWindowSize().columns || 80);
27
33
  const suggestRows = suggestRowCount(state.edit.buf, state.commands, cols);
28
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked, cursorTop: 3 + suggestRows })] }));
34
+ return (_jsxs(Box, { flexDirection: "column", paddingLeft: gutter, paddingRight: gutter, children: [_jsx(LogView, { entries: entries }), _jsx(StatusBar, { state: state }), _jsx(InputLine, { ...inputProps, store: store, prompt: state.prompt, locale: state.locale, commands: state.commands, locked: state.locked,
35
+ // 3 = divider + status + the box TOP border. The popup sits ABOVE
36
+ // the box and does not move it, so it must not shift the caret.
37
+ cursorTop: 3 })] }));
29
38
  }
@@ -12,13 +12,26 @@ const STATUS_ROWS = 1;
12
12
  export const HINT_ROWS = 1;
13
13
  export function fullScreenLayout(input) {
14
14
  const termRows = Math.max(1, Math.floor(input.termRows) || 24);
15
+ // `cols` is the WIDTH OF A PANE, not of the terminal: the full-screen app
16
+ // puts a gutter on both sides (screenInnerCols), and the input box wraps to
17
+ // exactly that inner width. Using the terminal width here reserved one row
18
+ // too few the moment a long input filled the pane (the box then wrapped an
19
+ // extra line and the caret drifted), so the caller MUST pass the inner
20
+ // width — and the pure helper stays consistent with what it renders.
15
21
  const cols = Math.max(1, Math.floor(input.cols) || 80);
16
22
  const box = inputBoxRows(input.prompt, input.buf, input.cursor, cols, input.borderCols ?? 2);
17
23
  const suggestRows = Math.max(0, Math.floor(input.suggestRows) || 0);
18
24
  const panelRows = HEADER_ROWS + STATUS_ROWS + box + suggestRows + HINT_ROWS;
19
25
  const logHeight = Math.max(1, termRows - panelRows);
20
26
  // The caret counts the SAME viewport the reservation uses.
21
- const cursorTop = HEADER_ROWS + logHeight + suggestRows + STATUS_ROWS + 1;
27
+ // The suggestion popup is drawn ABOVE the box and the box does NOT move, so
28
+ // the caret row must NOT include `suggestRows`: adding it put the terminal
29
+ // caret on the popup line (the operator saw the block cursor between
30
+ // `/chats` and the box). The popup only SHRINKS the log viewport — which
31
+ // `logHeight` above already accounts for.
32
+ // `cursorTop` is the row of the input BOX top border (the popup and the
33
+ // buffer rows are added by InputLine, where they are actually rendered).
34
+ const cursorTop = HEADER_ROWS + logHeight + STATUS_ROWS + 1;
22
35
  return {
23
36
  headerRows: HEADER_ROWS,
24
37
  statusRows: STATUS_ROWS,
package/dist/tui/store.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // The store keeps the WHOLE app state in one immutable snapshot object so
14
14
  // `useSyncExternalStore` gets a stable reference between changes (a new object
15
15
  // is produced only on an actual mutation).
16
+ import { DEFAULT_LOCALE } from '../i18n.js';
16
17
  import { initialEditState } from './edit.js';
17
18
  function initialState() {
18
19
  return {
@@ -34,7 +35,7 @@ function initialState() {
34
35
  history: [],
35
36
  prompt: '> ',
36
37
  commands: [],
37
- locale: 'ru',
38
+ locale: DEFAULT_LOCALE,
38
39
  contextText: null,
39
40
  contextLevel: null,
40
41
  queueLength: 0,
@@ -23,6 +23,8 @@ export function uiColorsFrom(p) {
23
23
  err: p.error,
24
24
  system: p.system,
25
25
  title: p.taskSummary,
26
+ userBar: p.userBar,
27
+ user: p.user,
26
28
  };
27
29
  }
28
30
  export const UI = uiColorsFrom(ALL_PALETTES[DEFAULT_THEME]);
package/dist/width.js CHANGED
@@ -4,11 +4,13 @@
4
4
  // is printed at the full terminal width while something else stops at 100 --
5
5
  // the mismatch looked ragged after a resize.
6
6
  //
7
- // 0 (default) = auto: the terminal width, capped at DEFAULT_CONTENT_WIDTH.
8
- // A longer line is harder to read and the eye loses the start of the next
9
- // line on a wide terminal, so there is a cap.
7
+ // 0 (default) = the FULL terminal width (minus one autowrap-safety column).
8
+ // The old default capped the content at 100 columns, which on a wide terminal
9
+ // left a wide empty band on the right of every answer; the operator asked for
10
+ // the text to use the whole width. A cap is still available per-run via
11
+ // `ui.answerWidth` when a narrower column is easier to read.
10
12
  // > 0 = an explicit cap (still never wider than the terminal).
11
- const DEFAULT_CONTENT_WIDTH = 100;
13
+ const DEFAULT_CONTENT_WIDTH = 0;
12
14
  let configured = 0;
13
15
  /** Set the configured content width (0 or a non-positive value = auto). */
14
16
  export function setContentWidth(width) {
@@ -21,5 +23,8 @@ export function setContentWidth(width) {
21
23
  export function contentWidth() {
22
24
  const cols = process.stdout.columns || 80;
23
25
  const cap = configured > 0 ? configured : DEFAULT_CONTENT_WIDTH;
24
- return Math.max(20, Math.min(cap, cols - 1));
26
+ // A cap of 0 means "no cap beyond the terminal": use every column except
27
+ // the last one (autowrap safety).
28
+ const limit = cap > 0 ? Math.min(cap, cols - 1) : cols - 1;
29
+ return Math.max(20, limit);
25
30
  }