jeo-code 0.7.3 → 0.7.4

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,20 @@ 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.7.4] - 2026-06-22
10
+ _Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop._
11
+
12
+ ### Fixed
13
+ - **Per-session model selection no longer leaks between concurrent sessions.** The per-turn model now falls back to the model resolved at THIS session's start (a frozen snapshot) instead of re-reading the live global `defaultModel`. Previously a second `jeo` session running `/model` persisted the global default (`rememberModelPatch`), and the first session's next turn would silently pick up that write mid-run. Running sessions are now model-isolated — they never influence each other.
14
+
15
+ ### Changed
16
+ - **Read-only code-inspection slash handlers are extracted into a pure module.** `/view`, `/diff`, `/find`, and `/search` move to `src/commands/launch/code-slash.ts` as self-contained async functions that take the raw input + cwd (and theme, for `/diff`) and return the lines to print — no closure-bound side effects. The REPL dispatch is now a thin `for (const line of await handle…) console.log(line)` adapter.
17
+ - **The `/undo` git slash and `/history` view are extracted for direct testing.** `handleUndoSlash` (in `src/commands/launch/git-slash.ts`) keeps its guard rail — it refuses any commit lacking the `[jeo] auto-commit:` prefix and any non-git tree — verifiable against a throwaway repo. `historyViewLines` (in `src/commands/launch/slash-views.ts`) makes the `/history [N|all]` banner/separator math and turn-count parsing pure over the in-memory transcript + terminal width.
18
+
19
+ ### Verified
20
+ - `bun run typecheck` clean; full suite **1848 pass / 0 fail** (225 files), including the new `test/launch-code-slash.test.ts`, `test/launch-git-slash.test.ts`, and `test/session-model-isolation.test.ts`, plus expanded `test/launch-slash-views.test.ts` (`/history`) and `test/tui-welcome.test.ts` (banner width tracks cols across a resize).
21
+
22
+
9
23
  ## [0.7.3] - 2026-06-22
10
24
  _Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback._
11
25
 
package/README.ja.md CHANGED
@@ -193,11 +193,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
193
193
  ## 変更履歴 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
196
197
  - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
197
198
  - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
200
- - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -193,11 +193,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
193
193
  ## 변경 이력 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
196
197
  - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
197
198
  - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
200
- - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -195,11 +195,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
195
195
  ## Changelog
196
196
 
197
197
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
198
+ - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
198
199
  - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
199
200
  - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
200
201
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
201
202
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
202
- - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
203
203
 
204
204
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
205
205
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -193,11 +193,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
193
193
  ## 更新日志 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.4]** (2026-06-22) — Per-session model isolation + REPL slash-handler testability: a running `jeo` session now pins the model it resolved at start, so a concurrent session running `/model` (which persists the global default) can no longer silently switch a different live session's model mid-run. The read-only code-inspection slashes (`/view`, `/diff`, `/find`, `/search`), the `/undo` git slash, and the `/history` view are extracted into pure, PTY-free handlers so their logic is unit-tested directly instead of buried in the REPL loop.
196
197
  - **[0.7.3]** (2026-06-22) — Provider catalog: the Tencent Cloud MaaS (international) `knownModels` list is broadened from a 4-id DeepSeek/MiniMax set to the full live-verified line-up across five families — DeepSeek, MiniMax, Zhipu GLM, Moonshot Kimi, and Hunyuan. Because the host exposes no `/v1/models` route, this hand-maintained list is the model picker's source of truth for the offline fallback.
197
198
  - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
200
- - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.3",
3
+ "version": "0.7.4",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Read-only code-inspection slash handlers extracted from launch.ts.
3
+ *
4
+ * Each handler is a self-contained async function that takes the raw slash
5
+ * input plus the working directory (and, for `/diff`, the active theme) and
6
+ * returns the lines to print. They perform no closure-bound side effects, so
7
+ * the REPL dispatch just prints whatever lines come back — matching the
8
+ * `{ lines }` contract used by the other extracted slash handlers.
9
+ */
10
+
11
+ import * as path from "node:path";
12
+ import * as fs from "node:fs";
13
+ import chalk from "chalk";
14
+ import { findTool, searchTool } from "../../agent/tools";
15
+ import {
16
+ detectLanguage,
17
+ languageLabel,
18
+ parseLineRange,
19
+ sliceLines,
20
+ formatCodeBlock,
21
+ formatDiff,
22
+ } from "../../tui/components/code-view";
23
+ import { categoryBadge } from "../../tui/components/category-index";
24
+ import { size as terminalSize } from "../../tui/terminal";
25
+ import type { EvolutionTheme } from "../../tui/components/themes";
26
+
27
+ /** `/view <file> [start-end]` — render a file (optionally a line range) as a code block. */
28
+ export async function handleViewSlash(input: string, cwd: string): Promise<string[]> {
29
+ const tokens = input.substring(5).trim().split(/\s+/).filter(Boolean);
30
+ const file = tokens[0];
31
+ if (!file) {
32
+ return ["Usage: /view <file> [start-end] (e.g. /view src/cli.ts 1-40)"];
33
+ }
34
+ let content: string;
35
+ try {
36
+ content = await fs.promises.readFile(path.resolve(cwd, file), "utf-8");
37
+ } catch (err) {
38
+ return [`! cannot read ${file}: ${(err as Error).message}`];
39
+ }
40
+ const range = tokens[1] ? parseLineRange(tokens[1]) : undefined;
41
+ if (tokens[1] && !range) {
42
+ return [`Invalid range '${tokens[1]}'. Use start-end | start- | start.`];
43
+ }
44
+ const lang = detectLanguage(file);
45
+ const { lines, startLine } = sliceLines(content, range ?? undefined);
46
+ const { cols } = terminalSize();
47
+ const out: string[] = [
48
+ `${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`,
49
+ ];
50
+ for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
51
+ out.push(line);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** `/diff [path]` — render `git diff` (optionally pathspec-scoped) with theme coloring. */
57
+ export async function handleDiffSlash(input: string, cwd: string, theme: EvolutionTheme): Promise<string[]> {
58
+ const target = input.substring(5).trim();
59
+ const proc = Bun.spawnSync(["git", "diff", ...(target ? ["--", target] : [])], { cwd, stdout: "pipe", stderr: "pipe" });
60
+ if (proc.exitCode !== 0 && !proc.stdout.length) {
61
+ return [`! git diff failed: ${proc.stderr.toString().trim() || "not a git repo?"}`];
62
+ }
63
+ const text = proc.stdout.toString();
64
+ if (!text.trim()) {
65
+ return ["(no unstaged changes)"];
66
+ }
67
+ const { cols } = terminalSize();
68
+ const out: string[] = [`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`];
69
+ for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme })) out.push(line);
70
+ return out;
71
+ }
72
+
73
+ /** `/find <glob>` — list files matching a glob via the shared findTool. */
74
+ export async function handleFindSlash(input: string, cwd: string): Promise<string[]> {
75
+ const glob = input.substring(5).trim();
76
+ if (!glob) {
77
+ return ["Usage: /find <glob> (e.g. /find src/**/*.ts)"];
78
+ }
79
+ const res = await findTool(glob, cwd);
80
+ return [
81
+ `${categoryBadge("search")} find files matching '${glob}':`,
82
+ res.success ? (res.output || "(no matches)") : `! ${res.error}`,
83
+ ];
84
+ }
85
+
86
+ /** `/search <pattern> [glob]` — grep the tree via the shared searchTool. */
87
+ export async function handleSearchSlash(input: string, cwd: string): Promise<string[]> {
88
+ const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
89
+ const pattern = tokens[0];
90
+ const glob = tokens[1] ?? "*";
91
+ if (!pattern) {
92
+ return ["Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)"];
93
+ }
94
+ const res = await searchTool(pattern, glob, cwd);
95
+ return [
96
+ `${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`,
97
+ res.success ? (res.output || "(no matches)") : `! ${res.error}`,
98
+ ];
99
+ }
@@ -0,0 +1,30 @@
1
+ // Self-contained git-backed REPL slash handlers. Each returns the exact line
2
+ // array the inline handler used to `console.log`, so the git side effects and
3
+ // their guard rails stay verifiable against a throwaway repo without driving the
4
+ // whole launch REPL. State comes in explicitly (cwd) — no closure capture.
5
+
6
+ /**
7
+ * `/undo` — revert the most recent commit, but ONLY when jeo made it (the
8
+ * `[jeo] auto-commit:` message prefix). Refuses to touch human commits or
9
+ * non-git trees. Performs the `git reset --hard HEAD~1` as a side effect and
10
+ * returns the status lines to print.
11
+ */
12
+ export function handleUndoSlash(cwd: string): string[] {
13
+ try {
14
+ const logRes = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd, stdout: "pipe", stderr: "ignore" });
15
+ if (logRes.exitCode !== 0) {
16
+ return ["! undo failed: could not read git log (not a git repo?)"];
17
+ }
18
+ const msg = logRes.stdout.toString().trim();
19
+ if (!msg.startsWith("[jeo] auto-commit:")) {
20
+ return ["! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert."];
21
+ }
22
+ const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
23
+ if (resetRes.exitCode === 0) {
24
+ return ["(undid the last jeo auto-commit and restored the working tree)"];
25
+ }
26
+ return [`! undo failed: ${resetRes.stderr.toString().trim()}`];
27
+ } catch (err) {
28
+ return [`! undo failed: ${(err as Error).message}`];
29
+ }
30
+ }
@@ -3,6 +3,8 @@
3
3
  // they can be unit-tested without driving the whole launch REPL. They hold NO
4
4
  // mutable session state — every input is passed in explicitly.
5
5
  import type { Message } from "../../agent/loop";
6
+ import { formatTranscript } from "../../tui/components/transcript";
7
+
6
8
 
7
9
  /** Static keyboard-shortcut reference for `/hotkeys` (no inputs, no state). */
8
10
  export function hotkeysLines(): string[] {
@@ -51,3 +53,21 @@ export function contextUsageLines(
51
53
  lines.push(" Free context with /compact or /clear.");
52
54
  return lines;
53
55
  }
56
+
57
+ /** Re-print the worked history into scrollback for `/history [N|all]`. Pure over
58
+ * the in-memory transcript plus the terminal width, so the banner/separator math
59
+ * and turn-count parsing are verifiable without driving the REPL.
60
+ * `arg` is the raw text after `/history` (e.g. " 10", " all", ""). */
61
+ export function historyViewLines(history: Message[], arg: string, columns: number | undefined): string[] {
62
+ const a = arg.trim().toLowerCase();
63
+ const maxTurns = a === "all" ? undefined : Math.max(1, Number.parseInt(a, 10) || 5);
64
+ const sep = "─".repeat(Math.min(48, Math.max(20, (columns ?? 80) - 1)));
65
+ return [
66
+ sep,
67
+ `history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`,
68
+ sep,
69
+ ...formatTranscript(history, { maxTurns, color: true, unicode: true }),
70
+ sep,
71
+ ];
72
+ }
73
+
@@ -60,7 +60,8 @@ import {
60
60
  import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } from "../tui/components/live-model-picker";
61
61
 
62
62
  import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
63
- import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
63
+ import { sanitizeForTerminal } from "../tui/components/code-view";
64
+
64
65
  import { categoryBadge } from "../tui/components/category-index";
65
66
  import { renderInputFrame, type HighlightRange } from "../tui/components/input-box";
66
67
 
@@ -78,7 +79,8 @@ import { stripMarkdown } from "../tui/components/markdown-text";
78
79
  import { summarizeForgeInvocation } from "../tui/components/forge";
79
80
  import { formatDuration, formatUsage } from "../tui/components/duration";
80
81
 
81
- import { findTool, searchTool, bashTool } from "../agent/tools";
82
+ import { bashTool } from "../agent/tools";
83
+
82
84
  import { loadProjectContext, withProjectContext } from "../agent/context-files";
83
85
  import { maybeCompact, historyTokens } from "../agent/compaction";
84
86
  import * as path from "node:path";
@@ -167,7 +169,9 @@ import {
167
169
  import {
168
170
  hotkeysLines,
169
171
  contextUsageLines,
172
+ historyViewLines,
170
173
  } from "./launch/slash-views";
174
+
171
175
  import {
172
176
  handleUsage,
173
177
  handleTools,
@@ -175,6 +179,15 @@ import {
175
179
  handleContext,
176
180
  type SlashContext,
177
181
  } from "./launch/slash-handlers";
182
+ import {
183
+ handleViewSlash,
184
+ handleDiffSlash,
185
+ handleFindSlash,
186
+ handleSearchSlash,
187
+ } from "./launch/code-slash";
188
+ import { handleUndoSlash } from "./launch/git-slash";
189
+
190
+
178
191
 
179
192
  export {
180
193
  type LaunchFlags,
@@ -692,7 +705,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
692
705
  opts?: { userCard?: string | null },
693
706
  ): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
694
707
  const turnConfig = await readGlobalConfig();
695
- const activeModel = sessionModel || turnConfig.defaultModel;
708
+ // Fall back to the model resolved at THIS session's start (frozen snapshot), NOT
709
+ // the live global default. A concurrent `jeo` session running `/model` persists the
710
+ // global default (rememberModelPatch), so re-reading turnConfig.defaultModel here let
711
+ // that write silently switch THIS running session's model mid-run. Per-session model
712
+ // selection must stay session-local: running sessions never influence each other.
713
+ const activeModel = sessionModel || defaultModel;
696
714
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
697
715
 
698
716
  // Resolve provider + dirty count up front — both are cheap and feed the live
@@ -730,7 +748,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
730
748
  tui.start();
731
749
  }
732
750
  const compRes = await maybeCompact(history, {
733
- model: sessionModel,
751
+ model: activeModel,
734
752
  contextTokens,
735
753
  });
736
754
  if (compRes.error) {
@@ -896,7 +914,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
896
914
  }
897
915
 
898
916
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
899
- const verdict = await verifyGoal(goalState.condition, history, sessionModel);
917
+ const verdict = await verifyGoal(goalState.condition, history, activeModel);
900
918
 
901
919
  goalState.verdicts.push({
902
920
  at: Date.now(),
@@ -938,7 +956,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
938
956
  const opik = createOpikTracer({
939
957
  name: userInput.trim().slice(0, 80) || "jeo turn",
940
958
  input: userInput,
941
- metadata: { model: sessionModel, cwd },
959
+ metadata: { model: activeModel, cwd },
942
960
  tags: ["jeo", "launch"],
943
961
  });
944
962
  opik.startTurn();
@@ -946,7 +964,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
946
964
  cwd,
947
965
  tools,
948
966
  maxSteps: flags.maxSteps,
949
- model: sessionModel,
967
+ model: activeModel,
950
968
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
951
969
  reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
952
970
  signal: ac.signal,
@@ -965,7 +983,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
965
983
  tools,
966
984
  maxSteps: Math.min(6, flags.maxSteps > 0 ? flags.maxSteps : 6),
967
985
  budget: { maxExtensions: 0 },
968
- model: sessionModel,
986
+ model: activeModel,
969
987
  maxTokens: sessionThinking ? thinkingMaxTokens(sessionThinking) : undefined,
970
988
  reasoningEffort: sessionThinking ? thinkingToReasoningEffort(sessionThinking) : undefined,
971
989
  signal: ac.signal,
@@ -1225,6 +1243,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1225
1243
  accent: accentPaint(welcomeTheme),
1226
1244
  accentShadow: accentShadowPaint(welcomeTheme),
1227
1245
  };
1246
+ // welcomeData.cols is a launch-time snapshot; re-measure on every banner re-render
1247
+ // (/clear, /resume) so the box fits the resized terminal instead of the old width.
1248
+ const freshWelcomeLines = (): string[] =>
1249
+ renderWelcome({ ...welcomeData, cols: terminalSize().cols });
1228
1250
  // gjc-style fresh-start clear so the banner opens atop a clean screen. TTY only,
1229
1251
  // never mid-turn (scrollback flood). ponytail: add an opt-out env if anyone misses their scrollback.
1230
1252
  if (process.stdout.isTTY) process.stdout.write(clearScreen());
@@ -2999,16 +3021,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2999
3021
  if (process.stdout.isTTY) {
3000
3022
  disarmPreview();
3001
3023
  process.stdout.write(clearScreen()); // clear screen + scrollback + cursor home
3002
- console.log(renderWelcome(welcomeData).join("\n"));
3024
+ console.log(freshWelcomeLines().join("\n"));
3003
3025
  }
3004
3026
  console.log("(history cleared — back to the start screen)");
3005
3027
  continue;
3006
3028
  }
3007
3029
  if (input === "/compact") {
3008
- const turnConfig = await readGlobalConfig();
3009
- const activeModel = sessionModel || turnConfig.defaultModel;
3030
+ // Session-start default snapshot (not the live global default) — keeps a
3031
+ // concurrent session's `/model` write from changing this session's context budget.
3032
+ const activeModel = sessionModel || defaultModel;
3010
3033
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
3011
- const res = await maybeCompact(history, { model: sessionModel, force: true, contextTokens });
3034
+ const res = await maybeCompact(history, { model: activeModel, force: true, contextTokens });
3012
3035
  if (res.error) {
3013
3036
  console.error(chalk.red(res.error));
3014
3037
  } else if (res.compacted && sessionId && res.replacesThrough !== undefined) {
@@ -3106,7 +3129,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3106
3129
  if (process.stdout.isTTY) {
3107
3130
  disarmPreview();
3108
3131
  process.stdout.write(clearScreen());
3109
- console.log(renderWelcome(welcomeData).join("\n"));
3132
+ console.log(freshWelcomeLines().join("\n"));
3110
3133
  }
3111
3134
  const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3112
3135
  logLines([
@@ -3267,13 +3290,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3267
3290
  if (input === "/history" || input.startsWith("/history ")) {
3268
3291
  // Re-print the worked history into scrollback (tmux-friendly review of
3269
3292
  // past prompts / tool steps / replies without terminal scrollback access).
3270
- const arg = input.slice("/history".length).trim().toLowerCase();
3271
- const maxTurns = arg === "all" ? undefined : Math.max(1, Number.parseInt(arg, 10) || 5);
3272
- const sep = "─".repeat(Math.min(48, Math.max(20, (process.stdout.columns ?? 80) - 1)));
3273
- logLines([sep, `history · last ${maxTurns ?? "all"} turn(s) (/history all for everything)`, sep,
3274
- ...formatTranscript(history, { maxTurns, color: true, unicode: true }), sep]);
3293
+ logLines(historyViewLines(history, input.slice("/history".length), process.stdout.columns));
3275
3294
  continue;
3276
3295
  }
3296
+
3277
3297
  if (input === "/export" || input.startsWith("/export ")) {
3278
3298
  if (!sessionId) {
3279
3299
  console.log("(sessions are disabled — nothing to export)");
@@ -3335,7 +3355,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3335
3355
  process.stdout.write("btw> ");
3336
3356
  let streamed = "";
3337
3357
  const answer = await callLlm(side, {
3338
- model: sessionModel,
3358
+ model: sessionModel || defaultModel,
3339
3359
  maxTokens: thinkingMaxTokens("low"),
3340
3360
  onToken: delta => {
3341
3361
  streamed += delta;
@@ -3350,28 +3370,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3350
3370
  continue;
3351
3371
  }
3352
3372
  if (input === "/undo") {
3353
- try {
3354
- const logRes = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd, stdout: "pipe", stderr: "ignore" });
3355
- if (logRes.exitCode === 0) {
3356
- const msg = logRes.stdout.toString().trim();
3357
- if (msg.startsWith("[jeo] auto-commit:")) {
3358
- const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
3359
- if (resetRes.exitCode === 0) {
3360
- console.log("(undid the last jeo auto-commit and restored the working tree)");
3361
- } else {
3362
- console.log(`! undo failed: ${resetRes.stderr.toString().trim()}`);
3363
- }
3364
- } else {
3365
- console.log("! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert.");
3366
- }
3367
- } else {
3368
- console.log("! undo failed: could not read git log (not a git repo?)");
3369
- }
3370
- } catch (err) {
3371
- console.log(`! undo failed: ${(err as Error).message}`);
3372
- }
3373
+ logLines(handleUndoSlash(cwd));
3374
+
3373
3375
  continue;
3374
3376
  }
3377
+
3375
3378
  if (input === "/goal" || input.startsWith("/goal ")) {
3376
3379
  const arg = input.substring(5).trim();
3377
3380
  if (!arg) {
@@ -4227,75 +4230,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4227
4230
  continue;
4228
4231
  }
4229
4232
  if (input.startsWith("/view") && (input === "/view" || input[5] === " ")) {
4230
- const tokens = input.substring(5).trim().split(/\s+/).filter(Boolean);
4231
- const file = tokens[0];
4232
- if (!file) {
4233
- console.log("Usage: /view <file> [start-end] (e.g. /view src/cli.ts 1-40)");
4234
- continue;
4235
- }
4236
- let content: string;
4237
- try {
4238
- content = await fs.promises.readFile(path.resolve(cwd, file), "utf-8");
4239
- } catch (err) {
4240
- console.log(`! cannot read ${file}: ${(err as Error).message}`);
4241
- continue;
4242
- }
4243
- const range = tokens[1] ? parseLineRange(tokens[1]) : undefined;
4244
- if (tokens[1] && !range) {
4245
- console.log(`Invalid range '${tokens[1]}'. Use start-end | start- | start.`);
4246
- continue;
4247
- }
4248
- const lang = detectLanguage(file);
4249
- const { lines, startLine } = sliceLines(content, range ?? undefined);
4250
- const { cols } = await import("../tui/terminal").then(m => m.size());
4251
- console.log(`${categoryBadge("file")} ${chalk.bold(`${file}`)}${chalk.gray(` (${languageLabel(lang)}, lines ${startLine}-${startLine + lines.length - 1})`)}`);
4252
- for (const line of formatCodeBlock(lines.join("\n"), { startLine, lang, cols: Math.max(40, cols - 1), maxLines: 200 })) {
4253
- console.log(line);
4254
- }
4233
+ for (const line of await handleViewSlash(input, cwd)) console.log(line);
4255
4234
  continue;
4256
4235
  }
4257
4236
  if (input.startsWith("/diff") && (input === "/diff" || input[5] === " ")) {
4258
- const target = input.substring(5).trim();
4259
- const proc = Bun.spawnSync(["git", "diff", ...(target ? ["--", target] : [])], { cwd, stdout: "pipe", stderr: "pipe" });
4260
- if (proc.exitCode !== 0 && !proc.stdout.length) {
4261
- console.log(`! git diff failed: ${proc.stderr.toString().trim() || "not a git repo?"}`);
4262
- continue;
4263
- }
4264
- const text = proc.stdout.toString();
4265
- if (!text.trim()) {
4266
- console.log("(no unstaged changes)");
4267
- continue;
4268
- }
4269
- const { cols } = await import("../tui/terminal").then(m => m.size());
4270
- console.log(`${categoryBadge("diff")} git diff${target ? ` -- ${target}` : ""}`);
4271
- for (const line of formatDiff(text, { cols: Math.max(40, cols - 1), maxLines: 400, theme: uiTheme })) console.log(line);
4237
+ for (const line of await handleDiffSlash(input, cwd, uiTheme)) console.log(line);
4272
4238
  continue;
4273
4239
  }
4274
4240
  if (input.startsWith("/find") && (input === "/find" || input[5] === " ")) {
4275
- const glob = input.substring(5).trim();
4276
- if (!glob) {
4277
- console.log("Usage: /find <glob> (e.g. /find src/**/*.ts)");
4278
- continue;
4279
- }
4280
- console.log(`${categoryBadge("search")} find files matching '${glob}':`);
4281
- const res = await findTool(glob, cwd);
4282
- console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
4241
+ for (const line of await handleFindSlash(input, cwd)) console.log(line);
4283
4242
  continue;
4284
4243
  }
4285
4244
  if (input.startsWith("/search") && (input === "/search" || input[7] === " ")) {
4286
- const tokens = input.substring(7).trim().split(/\s+/).filter(Boolean);
4287
- const pattern = tokens[0];
4288
- const glob = tokens[1] ?? "*";
4289
- if (!pattern) {
4290
- console.log("Usage: /search <pattern> [glob] (e.g. /search resolveProvider src/**/*.ts)");
4291
- continue;
4292
- }
4293
- console.log(`${categoryBadge("search")} search pattern '${pattern}' in '${glob}':`);
4294
- const res = await searchTool(pattern, glob, cwd);
4295
- console.log(res.success ? (res.output || "(no matches)") : `! ${res.error}`);
4245
+ for (const line of await handleSearchSlash(input, cwd)) console.log(line);
4296
4246
  continue;
4297
4247
  }
4298
4248
 
4249
+
4299
4250
  const skillEntrypoint = input.startsWith("/skill:") ? "/skill:" : input.startsWith("/skill") && (input === "/skill" || input[6] === " ") ? "/skill" : "";
4300
4251
  if (skillEntrypoint) {
4301
4252
  if (flags.noSkills) {