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 +19 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/commands/launch/flags.ts +13 -53
- package/src/commands/launch/input.ts +39 -1
- package/src/commands/launch.ts +53 -6
- package/src/tui/app.ts +23 -17
- package/src/tui/components/forge.ts +1 -1
- package/src/tui/components/input-box.ts +56 -0
- package/src/tui/components/welcome.ts +5 -10
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
|
@@ -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
|
|
89
|
-
|
|
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
|
-
|
|
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("--
|
|
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("--
|
|
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
|
-
|
|
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("--
|
|
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;
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
//
|
|
1666
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
1152
|
-
//
|
|
1153
|
-
const boxWidth = Math.max(floor,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
|
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
|
|
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: "│" };
|