jeo-code 0.6.5 → 0.6.7

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/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.7] - 2026-06-16
10
+ _Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width._
11
+
12
+ ### Fixed
13
+ - **Mouse reports no longer corrupt the prompt.** `jeo --tmux` enables tmux `mouse on` (so wheel-scroll reaches copy-mode), but the mouse-report bytes it delivers — X10 `ESC[M…` and SGR `ESC[<…M/m` — were landing in the input box as typed text (the "값 입력" digit spray when you click or scroll). A filter now swallows whole mouse-report sequences on both the idle keypress path and the live-turn raw-stdin drain, so they never reach readline.
14
+
15
+ ### Changed
16
+ - **TUI fills the full terminal width.** The welcome banner, input box, user/forge cards, history panel, and status box now share one wrap-safe `cols - 1` width instead of capping at 100/120 columns — every box lines up, and a full-width row never trips the terminal's last-column autowrap. The welcome banner's separate proportional/centered modes are dropped in favor of this single width.
17
+
18
+ ## [0.6.6] - 2026-06-16
19
+ _Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`._
20
+
21
+ ### Added
22
+ - **Vertical caret movement in the boxed prompt.** ↑/↓ inside a multi-line or wrapped draft now move the caret between the input box's visual rows (textarea feel) via `verticalCursorOffset`; an ↑/↓ at the top/bottom edge still falls through to readline history recall.
23
+
24
+ ### Changed
25
+ - **Welcome banner is centered.**
26
+ - **`parseFlags` simplified** — duplicate `--flag` / `--flag=` branches collapsed into one (`takeValue()` already resolves both spellings), −40 lines with zero behavior change.
27
+
9
28
  ## [0.6.5] - 2026-06-16
10
29
  _macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules._
11
30
 
package/README.ja.md CHANGED
@@ -162,11 +162,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
162
162
  ## 変更履歴 (Changelog)
163
163
 
164
164
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
165
+ - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
+ - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
167
  - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
168
  - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
167
169
  - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
168
- - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
169
- - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
170
170
 
171
171
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
172
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -162,11 +162,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
162
162
  ## 변경 이력 (Changelog)
163
163
 
164
164
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
165
+ - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
+ - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
167
  - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
168
  - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
167
169
  - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
168
- - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
169
- - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
170
170
 
171
171
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
172
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -162,11 +162,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
162
162
  ## Changelog
163
163
 
164
164
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
165
+ - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
+ - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
167
  - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
168
  - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
167
169
  - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
168
- - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
169
- - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
170
170
 
171
171
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
172
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -162,11 +162,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
162
162
  ## 更新日志 (Changelog)
163
163
 
164
164
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
165
+ - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
+ - **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
165
167
  - **[0.6.5]** (2026-06-16) — macOS combo-key editing in the boxed prompt, a fresh-start screen clear at launch, a proportional welcome banner, height-aware relayout — and `launch.ts` split into focused submodules.
166
168
  - **[0.6.4]** (2026-06-16) — Branding, a responsive-resize fix, `/provider` realignment, and engine repeat-spin recovery.
167
169
  - **[0.6.3]** (2026-06-16) — OAuth loopback reliability fix.
168
- - **[0.6.2]** (2026-06-16) — Interactive `/provider` picker, clearer animated status + labeled block/prose boundaries, and a transient empty-response retry.
169
- - **[0.6.1]** (2026-06-16) — Live reasoning progress (no more frozen "calling model"), thinking-level fixes for Anthropic/Antigravity, and input-box/Ctrl+O TUI fixes.
170
170
 
171
171
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
172
172
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.5",
3
+ "version": "0.6.7",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -84,46 +84,28 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
84
84
  flags.noSession = true;
85
85
  } else if (a === "--no-tui") {
86
86
  flags.noTui = true;
87
- } else if (a === "--max-steps") {
88
- const n = parseInt(args[i + 1] ?? "", 10);
89
- if (Number.isFinite(n) && n > 0) {
90
- flags.maxSteps = n;
91
- i++;
92
- }
93
- } else if (a.startsWith("--max-steps=")) {
94
- const n = parseInt(a.slice(12), 10);
87
+ } else if (a === "--max-steps" || a.startsWith("--max-steps=")) {
88
+ const { value, nextIndex } = takeValue(args, i, "--max-steps=");
89
+ const n = parseInt(value ?? "", 10);
95
90
  if (Number.isFinite(n) && n > 0) flags.maxSteps = n;
96
- } else if (a === "--model") {
91
+ i = nextIndex;
92
+ } else if (a === "--model" || a.startsWith("--model=")) {
97
93
  const { value, nextIndex } = takeValue(args, i, "--model=");
98
94
  if (value) flags.model = value;
99
95
  else flags.errors.push("--model requires a value");
100
96
  i = nextIndex;
101
- } else if (a.startsWith("--model=")) {
102
- const { value } = takeValue(args, i, "--model=");
103
- if (value) flags.model = value;
104
- else flags.errors.push("--model requires a value");
105
- } else if (a === "--provider") {
97
+ } else if (a === "--provider" || a.startsWith("--provider=")) {
106
98
  const { value, nextIndex } = takeValue(args, i, "--provider=");
107
99
  const normalized = value?.toLowerCase();
108
100
  if (isProviderName(normalized)) flags.provider = normalized;
109
101
  else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
110
102
  i = nextIndex;
111
- } else if (a.startsWith("--provider=")) {
112
- const { value } = takeValue(args, i, "--provider=");
113
- const normalized = value?.toLowerCase();
114
- if (isProviderName(normalized)) flags.provider = normalized;
115
- else flags.errors.push("--provider must be one of: anthropic, openai, gemini, ollama");
116
- } else if (a === "--thinking") {
103
+ } else if (a === "--thinking" || a.startsWith("--thinking=")) {
117
104
  const { value, nextIndex } = takeValue(args, i, "--thinking=");
118
105
  const normalized = value?.toLowerCase();
119
106
  if (isThinkingLevel(normalized)) flags.thinking = normalized;
120
107
  else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
121
108
  i = nextIndex;
122
- } else if (a.startsWith("--thinking=")) {
123
- const { value } = takeValue(args, i, "--thinking=");
124
- const normalized = value?.toLowerCase();
125
- if (isThinkingLevel(normalized)) flags.thinking = normalized;
126
- else flags.errors.push("--thinking must be one of: minimal, low, medium, high, xhigh");
127
109
  } else if (a === "--smol" || a === "--slow" || a === "--plan") {
128
110
  flags.modelRole = a.slice(2) as ModelRole;
129
111
  } else if (a === "--resume" || a === "--continue" || a === "-c") {
@@ -142,52 +124,30 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
142
124
  } else {
143
125
  rest.push(val);
144
126
  }
145
- } else if (a === "--append-system-prompt") {
127
+ } else if (a === "--append-system-prompt" || a.startsWith("--append-system-prompt=")) {
146
128
  const { value, nextIndex } = takeValue(args, i, "--append-system-prompt=");
147
- if (value) {
148
- flags.appendSystemPromptRaw = value;
149
- } else {
150
- flags.errors.push("--append-system-prompt requires a value");
151
- }
129
+ if (value) flags.appendSystemPromptRaw = value;
130
+ else flags.errors.push("--append-system-prompt requires a value");
152
131
  i = nextIndex;
153
- } else if (a.startsWith("--append-system-prompt=")) {
154
- const { value } = takeValue(args, i, "--append-system-prompt=");
155
- if (value) {
156
- flags.appendSystemPromptRaw = value;
157
- } else {
158
- flags.errors.push("--append-system-prompt requires a value");
159
- }
160
132
  } else if (a === "--no-skills") {
161
133
  flags.noSkills = true;
162
- } else if (a === "--skills") {
134
+ } else if (a === "--skills" || a.startsWith("--skills=")) {
163
135
  const { value, nextIndex } = takeValue(args, i, "--skills=");
164
136
  if (value) flags.skills = value;
165
137
  else flags.errors.push("--skills requires a value");
166
138
  i = nextIndex;
167
- } else if (a.startsWith("--skills=")) {
168
- const { value } = takeValue(args, i, "--skills=");
169
- if (value) flags.skills = value;
170
- else flags.errors.push("--skills requires a value");
171
139
  } else if (a === "--no-tools") {
172
140
  flags.noTools = true;
173
- } else if (a === "--tools") {
141
+ } else if (a === "--tools" || a.startsWith("--tools=")) {
174
142
  const { value, nextIndex } = takeValue(args, i, "--tools=");
175
143
  if (value) flags.tools = value;
176
144
  else flags.errors.push("--tools requires a value");
177
145
  i = nextIndex;
178
- } else if (a.startsWith("--tools=")) {
179
- const { value } = takeValue(args, i, "--tools=");
180
- if (value) flags.tools = value;
181
- else flags.errors.push("--tools requires a value");
182
- } else if (a === "--system-prompt") {
146
+ } else if (a === "--system-prompt" || a.startsWith("--system-prompt=")) {
183
147
  const { value, nextIndex } = takeValue(args, i, "--system-prompt=");
184
148
  if (value) flags.systemPromptRaw = value;
185
149
  else flags.errors.push("--system-prompt requires a value");
186
150
  i = nextIndex;
187
- } else if (a.startsWith("--system-prompt=")) {
188
- const { value } = takeValue(args, i, "--system-prompt=");
189
- if (value) flags.systemPromptRaw = value;
190
- else flags.errors.push("--system-prompt requires a value");
191
151
  } else {
192
152
  rest.push(a);
193
153
  }
@@ -83,6 +83,44 @@ export function matchCursorCombo(data: string, i: number): readonly [string, str
83
83
  return undefined;
84
84
  }
85
85
 
86
+ /** Byte length of a terminal MOUSE-REPORT sequence beginning at `data[i]`, else 0.
87
+ * jeo never requests mouse reporting (resetMouseTracking disables it), but tmux
88
+ * `mouse on` — which `jeo --tmux` sets so wheel-scroll reaches copy-mode — or a stale
89
+ * pane can still deliver reports. Their payload bytes (X10 `ESC[M` + 3 raw bytes, or
90
+ * SGR `ESC[<b;x;y` + `M`/`m`) would otherwise land in the prompt as typed text — the
91
+ * "값 입력" corruption where clicking/scrolling sprays digits into the input box. The
92
+ * filter swallows the whole sequence so it never reaches readline. `ESC[<` and `ESC[M`
93
+ * are input-unambiguous (mouse-only), so an unterminated tail (split across chunks) is
94
+ * consumed too rather than leaked. */
95
+ export function matchMouseReport(data: string, i: number): number {
96
+ if (data.startsWith("\u001b[<", i)) {
97
+ let j = i + 3;
98
+ while (j < data.length && data[j] !== "M" && data[j] !== "m") j++;
99
+ return (j < data.length ? j + 1 : data.length) - i;
100
+ }
101
+ if (data.startsWith("\u001b[M", i)) {
102
+ return Math.min(6, data.length - i);
103
+ }
104
+ return 0;
105
+ }
106
+
107
+ /** Remove every terminal MOUSE-REPORT sequence from a plain (non-paste) input segment.
108
+ * The live-turn drain (`queuePromptInputChunk`) reads RAW stdin, so a wheel/click report
109
+ * buffered during a running turn would otherwise have its printable remnant (`[M`, SGR
110
+ * digits) fed into the next prompt — the same "값 입력" corruption the keyFilter blocks
111
+ * on the idle path. */
112
+ export function stripMouseReports(s: string): string {
113
+ let out = "";
114
+ let i = 0;
115
+ while (i < s.length) {
116
+ const m = matchMouseReport(s, i);
117
+ if (m > 0) { i += m; continue; }
118
+ out += s[i];
119
+ i += 1;
120
+ }
121
+ return out;
122
+ }
123
+
86
124
  /** Apply combo-key rewrites across a plain (non-paste) input segment. Shares
87
125
  * `matchCursorCombo` with the live input filter, so the filter and this exported
88
126
  * helper can never diverge. */
@@ -169,7 +207,7 @@ export function queuePromptInputChunk(state: PromptInputQueue, chunk: string): b
169
207
  const plain = start === -1 ? rest : rest.slice(0, start);
170
208
  if (start !== -1) state.inPaste = true;
171
209
  rest = start === -1 ? "" : rest.slice(start + PASTE_START.length);
172
- if (feedTypedSegment(state, plain)) accepted = true;
210
+ if (feedTypedSegment(state, stripMouseReports(plain))) accepted = true;
173
211
  }
174
212
  }
175
213
  return accepted;
@@ -60,7 +60,8 @@ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } fro
60
60
  import { providerPicker, renderProviderPicker } from "../tui/components/provider-picker";
61
61
  import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
62
62
  import { categoryBadge } from "../tui/components/category-index";
63
- import { renderInputFrame } from "../tui/components/input-box";
63
+ import { renderInputFrame, verticalCursorOffset } from "../tui/components/input-box";
64
+
64
65
  import { renderStatusBar } from "../tui/components/status";
65
66
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
66
67
  import { readClipboardImage } from "../util/clipboard-image";
@@ -125,6 +126,8 @@ import {
125
126
  isStandaloneBackspace,
126
127
  CURSOR_COMBO_REWRITES,
127
128
  matchCursorCombo,
129
+ matchMouseReport,
130
+ stripMouseReports,
128
131
  rewriteCursorCombos,
129
132
  queuePromptInputChunk,
130
133
  captureLivePromptInputChunk,
@@ -171,6 +174,8 @@ export {
171
174
  isStandaloneBackspace,
172
175
  CURSOR_COMBO_REWRITES,
173
176
  matchCursorCombo,
177
+ matchMouseReport,
178
+ stripMouseReports,
174
179
  rewriteCursorCombos,
175
180
  queuePromptInputChunk,
176
181
  captureLivePromptInputChunk,
@@ -1221,7 +1226,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1221
1226
  let keyFilter: PassThrough | undefined;
1222
1227
  // Holder for the active readline so the input filter can see the current line
1223
1228
  // buffer (used by the empty-line backspace guard below). Set after rl is created.
1224
- let activeRl: { line?: string } | undefined;
1229
+ let activeRl: { line?: string; cursor?: number } | undefined;
1230
+
1225
1231
  if (multilineInput) {
1226
1232
  const kf = new PassThrough();
1227
1233
  (kf as unknown as { isTTY: boolean }).isTTY = true;
@@ -1258,6 +1264,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1258
1264
  if (data[i] === "\n" || data[i] === "\r") { out += SENTINEL; i += 1; continue; }
1259
1265
  out += data[i]; i += 1; continue;
1260
1266
  }
1267
+ // Swallow MOUSE-REPORT sequences (tmux `mouse on` from --tmux, or a stale pane):
1268
+ // their payload bytes would otherwise be typed into the prompt. Never in a paste.
1269
+ const mouse = matchMouseReport(data, i);
1270
+ if (mouse > 0) { i += mouse; continue; }
1261
1271
  let matched = false;
1262
1272
  for (const seq of SHIFT_ENTER_SEQS) {
1263
1273
  if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
@@ -1267,6 +1277,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1267
1277
  // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1268
1278
  const combo = matchCursorCombo(data, i);
1269
1279
  if (combo) { out += combo[1]; i += combo[0].length; continue; }
1280
+ // Up/Down inside a multi-line / wrapped draft move the caret between the box's
1281
+ // visual rows (textarea feel). Only when no slash list or history panel owns ↑/↓,
1282
+ // and only away from the top/bottom edge — at the edge the keys fall through to
1283
+ // readline so ↑/↓ still recalls input history.
1284
+ if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
1285
+ const dir = data[i + 2] === "A" ? "up" : "down";
1286
+ const line = activeRl?.line ?? "";
1287
+ if (line.length > 0 && navMatches.length === 0 && promptHistoryLines == null && activeRl) {
1288
+ const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
1289
+ const textWidth = Math.max(1, Math.max(24, winCols) - 6);
1290
+ const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
1291
+ const next = verticalCursorOffset(expandSentinel(line), cur, textWidth, dir);
1292
+ if (next != null) { activeRl.cursor = next; i += 3; continue; }
1293
+ }
1294
+ out += data.slice(i, i + 3); i += 3; continue;
1295
+ }
1270
1296
  if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
1271
1297
  out += data[i]; i += 1;
1272
1298
  }
@@ -1660,10 +1686,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1660
1686
  const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
1661
1687
  const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
1662
1688
  const frame = renderInputFrame(expandSentinel(line), {
1663
- // Cap the input box at 120 cols to match the live-turn box (renderLiveInputBox)
1664
- // and the user-card width, so the box doesn't visibly jump width on the
1665
- // idle→live transition on wide terminals. The status bar below stays full-width.
1666
- cols: Math.min(120, cols),
1689
+ // Full terminal width (cols is already columns - 1, leaving the last column free
1690
+ // so a full-width row never wraps). Matches the live-turn box, user/forge cards,
1691
+ // and the welcome banner all share this cols-1 width so nothing jumps on the
1692
+ // idle↔live transition. The status bar below stays full-width too.
1693
+ cols: cols,
1667
1694
  color: true,
1668
1695
  unicode: true,
1669
1696
  accent: boxAccent,
@@ -2331,6 +2358,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2331
2358
  lastIdleCols = cols;
2332
2359
  lastIdleRows = rows;
2333
2360
  try {
2361
+ // Fresh launch / post-`/clear`: the only thing on screen above the footer is the
2362
+ // welcome banner, which the terminal reflows into a fragmented mess on a width
2363
+ // change (every full-width box row wraps, floating its right border onto its own
2364
+ // line). Redraw it cleanly at the NEW width instead — full clear + reprint banner
2365
+ // + fresh footer reservation. Scoped to history.length <= 1, so once real work has
2366
+ // scrolled the banner into deep scrollback the caret-anchored relayout below runs.
2367
+ if (history.length <= 1 && process.stdout.isTTY) {
2368
+ footerRendered = 0;
2369
+ footerParkedRow = 0;
2370
+ lastFooterKey = "";
2371
+ lastDrawnLines = [];
2372
+ out.write(clearScreen());
2373
+ out.write(renderWelcome({ ...welcomeData, cols }).join("\n") + "\n");
2374
+ footerRows = previewRowsFor(rows);
2375
+ if (footerRows > 1) out.write("\n".repeat(footerRows - 1) + cursorUp(footerRows - 1));
2376
+ out.write(toColumn(1));
2377
+ footerRendered = footerRows;
2378
+ drawFooter(promptHistoryLines ? historyPreviewLines(promptHistoryLines) : previewLines(typedLine, navIdx));
2379
+ return;
2380
+ }
2334
2381
  // Resolution-safe relayout, anchored to the CURSOR (not the screen bottom). The
2335
2382
  // previous frame was painted at the OLD width; on resize the terminal reflows its
2336
2383
  // full-width rows AND repositions the frame — a width shrink wraps lines and floats
package/src/tui/app.ts CHANGED
@@ -325,7 +325,7 @@ export class LaunchTui {
325
325
  muted: mutedPaint(this.theme),
326
326
  accent: this.theme.color ? accentPaint(this.theme) : undefined,
327
327
  fill: cardFillPaint(this.theme),
328
- width: Math.max(24, Math.min(100, size().cols)),
328
+ width: Math.max(24, size().cols - 1),
329
329
  });
330
330
  this.appendLedger(card.join("\n") + "\n", "card");
331
331
  }
@@ -624,7 +624,7 @@ export class LaunchTui {
624
624
  const caret = this.unicode ? "▌" : "_";
625
625
  const display = this.livePromptInput ? `${this.livePromptInput}${caret}` : "";
626
626
  return renderInputBox(display, {
627
- cols: Math.max(24, Math.min(120, cols)),
627
+ cols: Math.max(24, cols),
628
628
  color: this.theme.color,
629
629
  unicode: this.unicode,
630
630
  accent: this.theme.color ? accentPaint(this.theme) : undefined,
@@ -639,7 +639,7 @@ export class LaunchTui {
639
639
  private renderUserCard(rawText: string, cols: number): string[] {
640
640
  const text = (rawText ?? "").trim();
641
641
  if (!text) return [];
642
- const boxWidth = Math.max(24, Math.min(120, cols));
642
+ const boxWidth = Math.max(24, cols);
643
643
  const inner = Math.max(10, boxWidth - 2);
644
644
  const g = this.unicode ? BOX_UNICODE : BOX_ASCII;
645
645
  const uc = this.theme.userCard;
@@ -668,7 +668,7 @@ export class LaunchTui {
668
668
  flushUserCard(text: string): void {
669
669
  const t = (text ?? "").trim();
670
670
  if (!t || this.finished) return;
671
- const cols = Math.max(20, size().cols);
671
+ const cols = Math.max(20, size().cols - 1);
672
672
  const lines = this.renderUserCard(t, cols);
673
673
  if (lines.length) this.appendLedger(lines.join("\n"), "card");
674
674
  }
@@ -1037,7 +1037,7 @@ export class LaunchTui {
1037
1037
  // Inline turns flushed every completed card into scrollback live; re-printing
1038
1038
  // the cards here would duplicate them right below themselves. A spacer row
1039
1039
  // keeps the card block from gluing to the stream above (jeo-ref rhythm).
1040
- const forge = this.renderForge(size().cols, 3);
1040
+ const forge = this.renderForge(size().cols - 1, 3);
1041
1041
  if (forge.length && finalLines.length) finalLines.push("");
1042
1042
  finalLines.push(...forge);
1043
1043
  }
@@ -1118,7 +1118,7 @@ export class LaunchTui {
1118
1118
  * Non-inline modes keep the card in `forgeSummaries` for the final static summary. */
1119
1119
  private flushForgeCard(summary: ForgeSummary, success?: boolean): void {
1120
1120
  if (!this.inline || this.finished) return;
1121
- const width = Math.max(24, Math.min(120, size().cols));
1121
+ const width = Math.max(24, size().cols - 1);
1122
1122
  // gjc D2 (state-encoded border): a FAILED card gets a red border so it pops
1123
1123
  // out of scrollback at a glance; OK/neutral cards keep the theme accent
1124
1124
  // identity. The ✓/✗ title mark already encodes state, but the border tone
@@ -1148,9 +1148,9 @@ export class LaunchTui {
1148
1148
  dim = false,
1149
1149
  ): string[] {
1150
1150
  const floor = Math.min(24, width);
1151
- // Fill the available width (cap at formatForgeBox's own 120 ceiling) so an
1152
- // in-frame box does not leave a dead right-margin column inside the outer panel.
1153
- const boxWidth = Math.max(floor, Math.min(120, width));
1151
+ // Fill the available width so an in-frame box does not leave a dead right-margin
1152
+ // column inside the outer panel.
1153
+ const boxWidth = Math.max(floor, width);
1154
1154
  const paint = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
1155
1155
  const lines: string[] = [];
1156
1156
  for (const [i, summary] of this.forgeSummaries.slice(-maxEntries).entries()) {
@@ -1181,7 +1181,7 @@ export class LaunchTui {
1181
1181
  /** Render the Ctrl+O panel inside the live frame. `maxRows` includes borders. */
1182
1182
  private renderHistoryPanel(width: number, maxRows: number): string[] {
1183
1183
  if (!this.historyLines || maxRows < 4) return [];
1184
- const boxWidth = Math.max(24, Math.min(120, width));
1184
+ const boxWidth = Math.max(24, width);
1185
1185
  const inner = Math.max(10, boxWidth - 2);
1186
1186
  const accent = this.theme.color ? accentPaint(this.theme) : (s: string) => s;
1187
1187
  const dim = this.theme.color ? chalk.dim : (s: string) => s;
@@ -1296,7 +1296,7 @@ export class LaunchTui {
1296
1296
  // so the in-progress trace stays shaded while the final record reads in normal text.
1297
1297
  const liveThink = this.streamingThought.trim() || this.streamingReasoning.trim();
1298
1298
  if (isThinking && liveThink) {
1299
- const wrapW = Math.max(8, Math.min(120, cols) - 2);
1299
+ const wrapW = Math.max(8, cols - 2);
1300
1300
  const wrapped = tailForWrap(liveThink)
1301
1301
  .split("\n")
1302
1302
  .flatMap(l => wrapTextWithAnsi(l, wrapW))
@@ -1307,7 +1307,7 @@ export class LaunchTui {
1307
1307
  // (duplicate model bar) is gone; height now toggles only at lifecycle boundaries.
1308
1308
  const ROWS = 6;
1309
1309
  const shown = wrapped.slice(-ROWS);
1310
- tail.push(sectionLabel("Thinking", Math.max(8, Math.min(120, cols)), { color: this.theme.color, unicode: this.unicode }));
1310
+ tail.push(sectionLabel("Thinking", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
1311
1311
  for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1312
1312
  for (const l of shown) tail.push(dim(` ${l}`));
1313
1313
  tail.push("");
@@ -1317,7 +1317,7 @@ export class LaunchTui {
1317
1317
  // output arrives via onToolProgress and is shown as a DIMMED, bounded tail block.
1318
1318
  // It is transient — cleared on result, when the formatted forge card takes over.
1319
1319
  if (this.runningTool && this.liveToolOutput.trim()) {
1320
- const wrapW = Math.max(8, Math.min(120, cols) - 2);
1320
+ const wrapW = Math.max(8, cols - 2);
1321
1321
  const wrapped = tailForWrap(this.liveToolOutput)
1322
1322
  .split("\n")
1323
1323
  .flatMap(l => wrapTextWithAnsi(l, wrapW))
@@ -1326,7 +1326,7 @@ export class LaunchTui {
1326
1326
  // so cumulative stdout growth does not thrash the frame height.
1327
1327
  const ROWS = 8;
1328
1328
  const shown = wrapped.slice(-ROWS);
1329
- tail.push(sectionLabel("Output", Math.max(8, Math.min(120, cols)), { color: this.theme.color, unicode: this.unicode }));
1329
+ tail.push(sectionLabel("Output", Math.max(8, cols), { color: this.theme.color, unicode: this.unicode }));
1330
1330
  for (let k = 0; k < ROWS - shown.length; k++) tail.push("");
1331
1331
  for (const l of shown) tail.push(dim(` ${l}`));
1332
1332
  tail.push("");
@@ -1336,7 +1336,7 @@ export class LaunchTui {
1336
1336
  // streamed activity is uniform across providers via streamingActivity and keeps
1337
1337
  // the ⟦esc⟧ cancel hint visible without trapping the message inside a border.
1338
1338
  if (isThinking) {
1339
- tail.push(...renderStatusBox(this.statusBoxData({ cols: Math.max(24, Math.min(120, cols)), elapsedMs, stepNow, phase, colorLevel, idx })));
1339
+ tail.push(...renderStatusBox(this.statusBoxData({ cols: Math.max(24, cols), elapsedMs, stepNow, phase, colorLevel, idx })));
1340
1340
  }
1341
1341
 
1342
1342
 
@@ -1410,7 +1410,10 @@ export class LaunchTui {
1410
1410
  const { cols, rows } = size();
1411
1411
  const fit = this.tty; // boxed full-screen layout only on a TTY (defaults to isTTY())
1412
1412
  const elapsedMs = this.startedAt ? Date.now() - this.startedAt : 0;
1413
- const innerWidth = fit && !this.inline ? cols - 4 : cols;
1413
+ // Inline frame fills the width but leaves the LAST column free (cols - 1) the same
1414
+ // wrap-safe convention as the welcome banner and idle input box, so every box lines up
1415
+ // at one width and a full-width row never trips the terminal's last-column autowrap.
1416
+ const innerWidth = !fit ? cols : this.inline ? cols - 1 : cols - 4;
1414
1417
 
1415
1418
  // Resolve the current (monotonic) stage for the track; announce a transition
1416
1419
  // once when it first advances. The header art is the DNA Claw brand symbol —
@@ -1462,7 +1465,10 @@ export class LaunchTui {
1462
1465
  // gjc-style inline frame: a flat stack (live card → status line → todos → hud →
1463
1466
  // model bar), no outer border, no mascot art — completed work lives in scrollback.
1464
1467
  if (fit && this.inline) {
1465
- const inlineFrame = this.composeInlineFrame({ cols, rows, stepNow, elapsedMs, idx, isThinking, planLines });
1468
+ // Pass cols - 1 so every in-frame box (input, model bar, forge, status) lines up
1469
+ // with the welcome banner, scrollback cards, and idle input box — and a full-width
1470
+ // row never trips the terminal's last-column autowrap (the 1-line=1-row guard).
1471
+ const inlineFrame = this.composeInlineFrame({ cols: Math.max(20, cols - 1), rows, stepNow, elapsedMs, idx, isThinking, planLines });
1466
1472
  // Screen-safety: every rendered line is width-clamped to `cols` so a long line
1467
1473
  // (e.g. the model bar with a deep cwd) cannot soft-wrap into a second physical row
1468
1474
  // and desync the differential renderer's 1-line=1-row accounting. Frame height stays
@@ -482,7 +482,7 @@ export function fitForgeBoxes(lines: string[], budget: number): string[] {
482
482
  export function formatForgeBox(summary: ForgeSummary, opts: ForgeBoxOptions = {}): string[] {
483
483
  const innerWidth = opts.width ?? 80;
484
484
  const floor = Math.min(24, innerWidth);
485
- const width = Math.max(floor, Math.min(120, Math.trunc(innerWidth)));
485
+ const width = Math.max(floor, Math.trunc(innerWidth));
486
486
  const maxLines = Math.max(1, Math.trunc(opts.maxLines ?? 10));
487
487
  const glyphs = borderGlyphs(opts.unicode);
488
488
  const paint = opts.paint ?? chalk.gray;
@@ -159,3 +159,59 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
159
159
  export function renderInputBox(line: string, opts: InputBoxOptions = {}): string[] {
160
160
  return renderInputFrame(line, opts).lines;
161
161
  }
162
+ /** Visual (row, display-col) of every caret position 0..N of `text` wrapped at `width`,
163
+ * using the SAME wrapping rule as the input box (`wrapWithCursor`). Index i is the caret
164
+ * sitting BEFORE char i (N = end of text); `cursor` offsets are code points, matching how
165
+ * `renderInputFrame` clamps `opts.cursor` against `Array.from(text)`. */
166
+ export interface CaretCell {
167
+ row: number;
168
+ col: number;
169
+ }
170
+ export function caretCells(text: string, width: number): CaretCell[] {
171
+ const cells: CaretCell[] = [];
172
+ let curW = 0;
173
+ let row = 0;
174
+ const chars = Array.from(text.replace(/\r/g, ""));
175
+ for (let i = 0; i <= chars.length; i++) {
176
+ const ch = i < chars.length ? chars[i]! : "";
177
+ const w = ch === "" || ch === "\n" ? 0 : ch === "\t" ? 2 : visibleWidth(ch);
178
+ // Wrap BEFORE recording the caret so a caret on a wrapping char follows it down — the
179
+ // exact order wrapWithCursor uses, so cell rows match the rendered box rows.
180
+ if (w > 0 && curW + w > width && curW > 0) { row += 1; curW = 0; }
181
+ cells.push({ row, col: curW });
182
+ if (ch === "\n") { row += 1; curW = 0; continue; }
183
+ if (ch !== "") curW += w;
184
+ }
185
+ return cells;
186
+ }
187
+
188
+ /** New caret offset after an Up/Down move within the wrapped input box, keeping the
189
+ * display column (textarea convention: snap to the nearest column ≤ the current one on the
190
+ * target row). Returns null when already on the top row (Up) or bottom row (Down), so the
191
+ * caller can fall through to readline's input-history recall. */
192
+ export function verticalCursorOffset(
193
+ text: string,
194
+ cursor: number,
195
+ width: number,
196
+ dir: "up" | "down",
197
+ ): number | null {
198
+ const cells = caretCells(text, Math.max(1, width));
199
+ if (cells.length === 0) return null;
200
+ const pos = Math.max(0, Math.min(cursor, cells.length - 1));
201
+ const curRow = cells[pos]!.row;
202
+ const targetRow = dir === "up" ? curRow - 1 : curRow + 1;
203
+ const maxRow = cells[cells.length - 1]!.row;
204
+ if (targetRow < 0 || targetRow > maxRow) return null;
205
+ const curCol = cells[pos]!.col;
206
+ let best = -1;
207
+ let bestCol = -1;
208
+ let firstOnRow = -1;
209
+ for (let p = 0; p < cells.length; p++) {
210
+ if (cells[p]!.row !== targetRow) continue;
211
+ if (firstOnRow === -1) firstOnRow = p;
212
+ const c = cells[p]!.col;
213
+ // Largest column not past the current one — the standard column-preserving snap.
214
+ if (c <= curCol && c > bestCol) { best = p; bestCol = c; }
215
+ }
216
+ return best !== -1 ? best : firstOnRow;
217
+ }
@@ -55,20 +55,15 @@ export function renderWelcome(d: WelcomeData): string[] {
55
55
  return [ `jeo v${d.version} · ${d.model}` ];
56
56
  }
57
57
 
58
- // The banner hugs a NATURAL hero width so it reads as a proportional box — the
59
- // grand DNA-claw framed by even margins instead of stretching into a mostly
60
- // empty rectangle on wide terminals. When the terminal is SMALLER than that
61
- // initial/native width it shrinks proportionally: the box narrows and the claw
62
- // steps grand→compact (below) so the art keeps its shape and never clips.
58
+ // The banner fills the full terminal width (gjc forge: flush with the input box and
59
+ // status bar below it). `cols - 1` leaves the last column free so a full-width row
60
+ // never wraps; the DNA-claw + pills stay centered inside the box.
63
61
  const grandWidth = Math.max(...DNA_CLAW_ART_GRAND.map(l => l.length));
64
62
  // Title rides ON the top border: `─── jeo v{version} · JEO forge ───`. Defined
65
- // once here so the natural-width calc and the border render below can't drift.
63
+ // once here so the width calc and the border render below can't drift.
66
64
  const titleDashes = 3;
67
65
  const titleLabel = ` jeo v${d.version} · JEO forge `;
68
- const titleWidth = titleDashes + titleLabel.length;
69
- // grand art (36) + a 6-col margin each side, never narrower than the title.
70
- const naturalInner = Math.max(grandWidth + 12, titleWidth + 2);
71
- const W = Math.min(cols - 2, naturalInner + 2);
66
+ const W = cols - 1;
72
67
  const inner = W - 2;
73
68
 
74
69
  const BOX_UNICODE = { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" };