jeo-code 0.6.18 → 0.6.20
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 +26 -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 -2
- package/src/agent/engine.ts +39 -32
- package/src/agent/hooks.ts +103 -34
- package/src/commands/launch/mentions.ts +48 -0
- package/src/commands/launch/slash-handlers.ts +62 -0
- package/src/commands/launch/slash-views.ts +48 -0
- package/src/commands/launch.ts +35 -76
- package/src/tui/components/width.ts +42 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ 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.20] - 2026-06-18
|
|
10
|
+
_Launch REPL internals decomposed into testable modules: `@mention` path completion, slash-command view renderers, and slash-command handlers extracted from the monolithic `launch.ts` into dedicated files with full unit-test coverage._
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **`mentionPaths` / `currentAtLabel` extracted to `src/commands/launch/mentions.ts`.** The `@path` filesystem completion and footer label logic are now pure, `cwd`-parametric functions that can be unit-tested in isolation; `launch.ts` delegates to them via thin wrappers.
|
|
14
|
+
- **Slash-command view renderers extracted to `src/commands/launch/slash-views.ts`.** `hotkeysLines` and `contextUsageLines` are now pure functions (no I/O, no hidden state) returning `string[]`; verified with snapshot-style unit tests.
|
|
15
|
+
- **Slash-command handlers extracted to `src/commands/launch/slash-handlers.ts`.** `/usage`, `/tools`, `/hotkeys`, `/context` handlers are isolated behind a `SlashContext` interface, each returning a typed result — testable without spinning up the full REPL.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- **Unit tests for all extracted modules** (`test/launch-mentions.test.ts`, `test/launch-slash-views.test.ts`, `test/slash-handlers.test.ts`): 13 new tests, 79 assertions — mentionPaths directory traversal, case-insensitive filtering, unreadable-dir guard, currentAtLabel edge cases, context token tallies, singular/plural spacing, handler outputs.
|
|
19
|
+
|
|
20
|
+
## [0.6.19] - 2026-06-18
|
|
21
|
+
|
|
22
|
+
_Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint._
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **Post-turn hooks execute ONCE per multi-call batch instead of once per result.** A project-wide checker hook (`tsc --noEmit`/lint/test) whose `match.tool` matched every edit in a batch previously re-ran N times sequentially — the dominant in-loop latency multiplier on multi-edit turns. `runPostTurnHooksForBatch` now groups the batch's calls, invokes each matching hook a single time, and runs distinct hooks concurrently. A hook matching several calls receives a back-compatible payload (`{event,tool,args,success,output}` plus a `calls[]` array of every matched call) so a payload-aware per-file hook can still iterate the changed files in one invocation; a single match keeps the exact legacy shape. The single-call `runPostTurnHooks` is retained as a thin wrapper for direct callers/tests.
|
|
26
|
+
- **Tool-result bodies are formatted in parallel.** The per-result loop that serialized body formatting (and any oversized-body spill to a disk artifact) is replaced by a `Promise.all` over the batch, so independent formatting/disk writes overlap.
|
|
27
|
+
|
|
28
|
+
### Performance
|
|
29
|
+
- **Local `.jeo/hooks.json` is mtime/size-cached.** The per-project hook override was re-read and re-parsed (`fs.readFile` + `JSON.parse`) on every `loadHooks` call — once per pre-tool check and once per post-turn batch. It is now cached keyed by absolute path → mtime/size (bounded LRU, cap 32) and only re-read when the file actually changes, so any external write is still picked up immediately without a stale serve.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- **Wrapped colored text keeps its color on every continuation row.** `wrapTextWithAnsi` is now SGR-stateful: a color opened before a wrap point is re-applied at the start of each continuation line and closed at its end, so a wrapped colored span stays tinted on every row (the reported "color breaks when the line wraps") instead of losing its tint after the first line — and never bleeds into the padding or box border. Plain uncolored text is left byte-for-byte unchanged.
|
|
33
|
+
|
|
34
|
+
|
|
9
35
|
## [0.6.18] - 2026-06-17
|
|
10
36
|
_Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior._
|
|
11
37
|
|
package/README.ja.md
CHANGED
|
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
200
200
|
## 変更履歴 (Changelog)
|
|
201
201
|
|
|
202
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.20]** (2026-06-18) — Launch REPL internals decomposed into testable modules: `@mention` path completion, slash-command view renderers, and slash-command handlers extracted from the monolithic `launch.ts` into dedicated files with full unit-test coverage.
|
|
204
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
203
205
|
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
204
206
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
205
207
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
206
|
-
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
207
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
208
208
|
|
|
209
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
210
210
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
200
200
|
## 변경 이력 (Changelog)
|
|
201
201
|
|
|
202
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.20]** (2026-06-18) — Launch REPL internals decomposed into testable modules: `@mention` path completion, slash-command view renderers, and slash-command handlers extracted from the monolithic `launch.ts` into dedicated files with full unit-test coverage.
|
|
204
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
203
205
|
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
204
206
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
205
207
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
206
|
-
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
207
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
208
208
|
|
|
209
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
210
210
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -200,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
200
200
|
## Changelog
|
|
201
201
|
|
|
202
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.20]** (2026-06-18) — Launch REPL internals decomposed into testable modules: `@mention` path completion, slash-command view renderers, and slash-command handlers extracted from the monolithic `launch.ts` into dedicated files with full unit-test coverage.
|
|
204
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
203
205
|
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
204
206
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
205
207
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
206
|
-
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
207
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
208
208
|
|
|
209
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
210
210
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
200
200
|
## 更新日志 (Changelog)
|
|
201
201
|
|
|
202
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.20]** (2026-06-18) — Launch REPL internals decomposed into testable modules: `@mention` path completion, slash-command view renderers, and slash-command handlers extracted from the monolithic `launch.ts` into dedicated files with full unit-test coverage.
|
|
204
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
203
205
|
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
204
206
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
205
207
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
206
|
-
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
207
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
208
208
|
|
|
209
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
210
210
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool,
|
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
17
17
|
import { friendlyProviderError, isContextOverflowError, isRefusalError } from "../util/provider-error";
|
|
18
18
|
import { isRateLimitError } from "../util/retry";
|
|
19
|
-
import { runPreToolHooks,
|
|
19
|
+
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
20
20
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
21
21
|
export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
|
|
22
22
|
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
|
|
@@ -903,46 +903,53 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
903
903
|
}
|
|
904
904
|
|
|
905
905
|
const processAndPushResults = async (indices: number[]) => {
|
|
906
|
-
|
|
907
|
-
// Per-batch dedup of post-turn hook diagnostics: a whole-project `tsc` hook
|
|
908
|
-
// matching every edit in a batch yields identical output N times — show it
|
|
909
|
-
// once, cross-reference the rest (cycle 13).
|
|
910
|
-
const seenHookFeedback = new Set<string>();
|
|
906
|
+
// Surface completion to the TUI ledger in call order.
|
|
911
907
|
for (const idx of indices) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
908
|
+
ev.onToolResult?.(toolCalls[idx].tool, results[idx].success, results[idx].output);
|
|
909
|
+
}
|
|
910
|
+
// Format every result body in PARALLEL — independent work, and an oversized
|
|
911
|
+
// body may spill to a disk artifact; the previous per-result loop serialized
|
|
912
|
+
// both the formatting and the disk writes.
|
|
913
|
+
const bodies = await Promise.all(
|
|
914
|
+
indices.map(idx => formatToolResultBody(toolCalls[idx].tool, results[idx].output, cwd)),
|
|
915
|
+
);
|
|
916
|
+
// Run post-turn hooks ONCE for the whole batch instead of once per result: a
|
|
917
|
+
// project-wide `tsc`/lint/test hook matching every edit in the batch no longer
|
|
918
|
+
// re-executes N times sequentially (the dominant in-loop latency multiplier).
|
|
919
|
+
const { diags: hookDiags, ran: hooksRan } = await runPostTurnHooksForBatch(
|
|
920
|
+
cwd,
|
|
921
|
+
indices.map(idx => ({
|
|
922
|
+
tool: toolCalls[idx].tool,
|
|
923
|
+
args: toolCalls[idx].arguments ?? {},
|
|
924
|
+
success: results[idx].success,
|
|
925
|
+
output: results[idx].output,
|
|
926
|
+
})),
|
|
927
|
+
opts.signal,
|
|
928
|
+
ev.onNotice,
|
|
929
|
+
);
|
|
930
|
+
// F1: a red hook becomes a pending failure the done guard enforces; a later
|
|
931
|
+
// batch whose hooks complete CLEAN (ran > 0, zero diags) clears it.
|
|
932
|
+
if (hookDiags.length > 0) pendingHookFailure = hookDiags[hookDiags.length - 1].run;
|
|
933
|
+
else if (hooksRan > 0) pendingHookFailure = null;
|
|
932
934
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
935
|
+
const resultBlocks: string[] = indices.map((idx, i) =>
|
|
936
|
+
`Tool [${toolCalls[idx].tool}] result (${results[idx].success ? "ok" : "fail"}):\n${bodies[i]}`,
|
|
937
|
+
);
|
|
938
|
+
// Append the batch's hook diagnostics once so the model can self-correct. Two
|
|
939
|
+
// DISTINCT hooks with identical output collapse to one full block + a cross-ref.
|
|
940
|
+
if (hookDiags.length > 0) {
|
|
941
|
+
const seenHookFeedback = new Set<string>();
|
|
942
|
+
const diagLines: string[] = [];
|
|
936
943
|
for (const d of hookDiags) {
|
|
937
944
|
const key = `${d.run}\u0000${d.output}`;
|
|
938
945
|
if (seenHookFeedback.has(key)) {
|
|
939
|
-
|
|
946
|
+
diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}: same diagnostics as above]`);
|
|
940
947
|
} else {
|
|
941
948
|
seenHookFeedback.add(key);
|
|
942
|
-
|
|
949
|
+
diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}]:\n${truncateToolOutput(d.output)}`);
|
|
943
950
|
}
|
|
944
951
|
}
|
|
945
|
-
resultBlocks.push(
|
|
952
|
+
resultBlocks.push(diagLines.join("\n"));
|
|
946
953
|
}
|
|
947
954
|
|
|
948
955
|
history.push({ role: "assistant", content: responseText });
|
package/src/agent/hooks.ts
CHANGED
|
@@ -22,27 +22,56 @@ export interface PostTurnHookDiag {
|
|
|
22
22
|
output: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Local `.jeo/hooks.json` read cache, keyed by absolute path → mtime/size. The
|
|
26
|
+
// global config is already mtime-cached in state.ts, but the per-project local
|
|
27
|
+
// override was re-read (fs.readFile + JSON.parse) on every loadHooks call — once
|
|
28
|
+
// per pre-tool hook check and once per post-turn batch. Cache the parsed outcome
|
|
29
|
+
// and re-read only when the file's mtime/size changes (any external write bumps
|
|
30
|
+
// both, so a stale entry is never served).
|
|
31
|
+
type LocalHooks =
|
|
32
|
+
| { kind: "disabled" }
|
|
33
|
+
| { kind: "hooks"; hooks: NonNullable<HookConfig["hooks"]> }
|
|
34
|
+
| { kind: "fallback" };
|
|
35
|
+
const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
|
|
36
|
+
const LOCAL_HOOKS_CACHE_CAP = 32;
|
|
37
|
+
|
|
38
|
+
async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
39
|
+
let st: { mtimeMs: number; size: number };
|
|
40
|
+
try {
|
|
41
|
+
st = await fs.stat(localPath);
|
|
42
|
+
} catch {
|
|
43
|
+
localHooksCache.delete(localPath);
|
|
44
|
+
return { kind: "fallback" };
|
|
45
|
+
}
|
|
46
|
+
const hit = localHooksCache.get(localPath);
|
|
47
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.result;
|
|
48
|
+
let result: LocalHooks = { kind: "fallback" };
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
|
|
51
|
+
if (parsed && typeof parsed === "object") {
|
|
52
|
+
if (parsed.enabled === false) result = { kind: "disabled" };
|
|
53
|
+
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Missing/invalid local file → fall back to the global config hooks.
|
|
57
|
+
}
|
|
58
|
+
if (localHooksCache.size >= LOCAL_HOOKS_CACHE_CAP && !localHooksCache.has(localPath)) {
|
|
59
|
+
const oldest = localHooksCache.keys().next().value;
|
|
60
|
+
if (oldest !== undefined) localHooksCache.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
localHooksCache.set(localPath, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export async function loadHooks(cwd: string): Promise<NonNullable<HookConfig["hooks"]>> {
|
|
26
67
|
const config = await readGlobalConfig();
|
|
27
68
|
if (!config.hooks?.enabled) {
|
|
28
69
|
return [];
|
|
29
70
|
}
|
|
30
71
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const parsed = JSON.parse(content);
|
|
35
|
-
if (parsed && typeof parsed === "object") {
|
|
36
|
-
if (parsed.enabled === false) {
|
|
37
|
-
return [];
|
|
38
|
-
}
|
|
39
|
-
if (Array.isArray(parsed.hooks)) {
|
|
40
|
-
return parsed.hooks;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
} catch (e) {
|
|
44
|
-
// If local file is missing or invalid, fall back to global
|
|
45
|
-
}
|
|
72
|
+
const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
73
|
+
if (local.kind === "disabled") return [];
|
|
74
|
+
if (local.kind === "hooks") return local.hooks;
|
|
46
75
|
|
|
47
76
|
return config.hooks?.hooks || [];
|
|
48
77
|
}
|
|
@@ -180,12 +209,32 @@ export async function runPreToolHooks(
|
|
|
180
209
|
return { vetoed: false };
|
|
181
210
|
}
|
|
182
211
|
|
|
183
|
-
|
|
212
|
+
/** One executed tool call fed to the post-turn hooks of a batch. */
|
|
213
|
+
export interface PostTurnCall {
|
|
214
|
+
tool: string;
|
|
215
|
+
args: Record<string, any>;
|
|
216
|
+
success: boolean;
|
|
217
|
+
output: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function outputPreviewOf(output: string): string {
|
|
221
|
+
return output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run post-turn hooks for a whole batch of executed tool calls, invoking each
|
|
226
|
+
* matching hook EXACTLY ONCE — not once per result. A project-wide checker
|
|
227
|
+
* (`tsc --noEmit`/lint/test) that matches every edit in a batch previously ran N
|
|
228
|
+
* times sequentially; now it runs a single time. Distinct hooks run concurrently.
|
|
229
|
+
*
|
|
230
|
+
* Payload back-compat: a hook that matches a single call gets the legacy
|
|
231
|
+
* `{event,tool,args,success,output}` shape; a hook matching several gets the same
|
|
232
|
+
* fields plus a `calls[]` array (every matched call) so a payload-aware per-file
|
|
233
|
+
* hook can still iterate the changed files in one invocation.
|
|
234
|
+
*/
|
|
235
|
+
export async function runPostTurnHooksForBatch(
|
|
184
236
|
cwd: string,
|
|
185
|
-
|
|
186
|
-
args: Record<string, any>,
|
|
187
|
-
success: boolean,
|
|
188
|
-
output: string,
|
|
237
|
+
calls: readonly PostTurnCall[],
|
|
189
238
|
signal?: AbortSignal,
|
|
190
239
|
onNotice?: (msg: string) => void
|
|
191
240
|
): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
|
|
@@ -195,23 +244,29 @@ export async function runPostTurnHooks(
|
|
|
195
244
|
let ran = 0;
|
|
196
245
|
try {
|
|
197
246
|
const hooks = await loadHooks(cwd);
|
|
198
|
-
const
|
|
199
|
-
h => h.event === "post-turn"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const outputPreview = output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
|
|
203
|
-
|
|
204
|
-
for (const hook of postTurnHooks) {
|
|
205
|
-
const payload = {
|
|
206
|
-
event: "post-turn",
|
|
207
|
-
tool,
|
|
208
|
-
args,
|
|
209
|
-
success,
|
|
210
|
-
output: outputPreview,
|
|
211
|
-
};
|
|
247
|
+
const jobs = hooks
|
|
248
|
+
.filter(h => h.event === "post-turn")
|
|
249
|
+
.map(hook => ({ hook, matched: calls.filter(c => hookMatchesTool(hook.match?.tool, c.tool)) }))
|
|
250
|
+
.filter(j => j.matched.length > 0);
|
|
212
251
|
|
|
252
|
+
// Distinct hooks are independent commands → run them concurrently. Each hook
|
|
253
|
+
// itself runs once for the whole batch (the redundancy this fix removes).
|
|
254
|
+
const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
|
|
255
|
+
const payload = matched.length === 1
|
|
256
|
+
? { event: "post-turn", tool: matched[0].tool, args: matched[0].args, success: matched[0].success, output: outputPreviewOf(matched[0].output) }
|
|
257
|
+
: {
|
|
258
|
+
event: "post-turn",
|
|
259
|
+
tool: matched.map(c => c.tool).join(","),
|
|
260
|
+
calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
|
|
261
|
+
success: matched.every(c => c.success),
|
|
262
|
+
output: outputPreviewOf(matched.map(c => c.output).join("\n")),
|
|
263
|
+
};
|
|
213
264
|
const timeoutMs = hook.timeoutMs || 30000;
|
|
214
265
|
const result = await runHookCommand(hook.run, payload, cwd, timeoutMs, signal);
|
|
266
|
+
return { hook, result };
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
for (const { hook, result } of settled) {
|
|
215
270
|
if (result.timedOut) {
|
|
216
271
|
onNotice?.(`Post-turn hook "${hook.run}" timed out (advisory).`);
|
|
217
272
|
} else if (result.aborted) {
|
|
@@ -235,6 +290,20 @@ export async function runPostTurnHooks(
|
|
|
235
290
|
return { diags, ran };
|
|
236
291
|
}
|
|
237
292
|
|
|
293
|
+
/** Single-call convenience wrapper (kept for direct callers/tests). Delegates to
|
|
294
|
+
* the batch runner with one call, preserving the legacy payload shape. */
|
|
295
|
+
export async function runPostTurnHooks(
|
|
296
|
+
cwd: string,
|
|
297
|
+
tool: string,
|
|
298
|
+
args: Record<string, any>,
|
|
299
|
+
success: boolean,
|
|
300
|
+
output: string,
|
|
301
|
+
signal?: AbortSignal,
|
|
302
|
+
onNotice?: (msg: string) => void
|
|
303
|
+
): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
|
|
304
|
+
return runPostTurnHooksForBatch(cwd, [{ tool, args, success, output }], signal, onNotice);
|
|
305
|
+
}
|
|
306
|
+
|
|
238
307
|
export async function runPostImplementationHooks(
|
|
239
308
|
cwd: string,
|
|
240
309
|
request: string,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { tokenize } from "../../tui/components/autocomplete";
|
|
4
|
+
|
|
5
|
+
/** Filesystem completion for an `@path` mention prefix, relative to `cwd`.
|
|
6
|
+
* Lists the directory the prefix points at, filtering by the basename fragment,
|
|
7
|
+
* directories first, hidden entries dropped, capped at 50 — directories keep a
|
|
8
|
+
* trailing `/` so the next Tab descends. Pure given `cwd`: never throws (an
|
|
9
|
+
* unreadable directory yields no matches). Extracted verbatim from the launch
|
|
10
|
+
* REPL so it can be unit-tested in isolation. */
|
|
11
|
+
export function mentionPaths(cwd: string, prefix: string): string[] {
|
|
12
|
+
const norm = prefix.replace(/\\/g, "/");
|
|
13
|
+
const wantsDirChildren = norm.endsWith("/");
|
|
14
|
+
const dirPart = wantsDirChildren ? norm.slice(0, -1) : path.posix.dirname(norm) === "." ? "" : path.posix.dirname(norm);
|
|
15
|
+
const namePart = wantsDirChildren ? "" : path.posix.basename(norm);
|
|
16
|
+
const absDir = path.resolve(cwd, dirPart || ".");
|
|
17
|
+
let entries: fs.Dirent[] = [];
|
|
18
|
+
try {
|
|
19
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
20
|
+
} catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return entries
|
|
24
|
+
.filter(entry => !entry.name.startsWith("."))
|
|
25
|
+
.filter(entry => !namePart || entry.name.toLowerCase().startsWith(namePart.toLowerCase()))
|
|
26
|
+
.sort((a, b) => {
|
|
27
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
28
|
+
return a.name.localeCompare(b.name);
|
|
29
|
+
})
|
|
30
|
+
.slice(0, 50)
|
|
31
|
+
.map(entry => {
|
|
32
|
+
const rel = dirPart ? `${dirPart}/${entry.name}` : entry.name;
|
|
33
|
+
return entry.isDirectory() ? `${rel}/` : rel;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The `@ <dir>` label shown in the boxed-input footer for the last `@mention`
|
|
38
|
+
* token on the current line, or undefined when the line has no mention. Pure. */
|
|
39
|
+
export function currentAtLabel(line: string): string | undefined {
|
|
40
|
+
const { tokens } = tokenize(line);
|
|
41
|
+
const token = [...tokens].reverse().find(t => t.startsWith("@"));
|
|
42
|
+
if (!token) return undefined;
|
|
43
|
+
const norm = token.slice(1).replace(/\\/g, "/");
|
|
44
|
+
if (!norm) return "@ .";
|
|
45
|
+
if (norm.endsWith("/")) return `@ ${norm.slice(0, -1) || "."}`;
|
|
46
|
+
const dir = path.posix.dirname(norm);
|
|
47
|
+
return `@ ${dir === "." ? norm : dir}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash command handlers extracted from launch.ts.
|
|
3
|
+
* Each handler is a pure function taking context and returning a result.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message } from "../../agent/loop";
|
|
7
|
+
import type { Config } from "../../agent/state";
|
|
8
|
+
import { TOOL_PROTOCOL } from "../../agent/engine";
|
|
9
|
+
import { taskToolProtocolLine } from "../../agent/task-tool";
|
|
10
|
+
import { TODO_TOOL_PROTOCOL_LINE } from "../../agent/todo-tool";
|
|
11
|
+
import { SUBAGENT_TOOL_PROTOCOL_LINE } from "../../agent/subagent-tool";
|
|
12
|
+
import { hotkeysLines, contextUsageLines } from "./slash-views";
|
|
13
|
+
import { describeModel, catalogMetadata } from "../../ai";
|
|
14
|
+
import { readGlobalConfig } from "../../agent/state";
|
|
15
|
+
|
|
16
|
+
/** Shared context passed to all slash handlers. */
|
|
17
|
+
export interface SlashContext {
|
|
18
|
+
history: Message[];
|
|
19
|
+
sessionModel?: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
config: Config;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Handler result: lines to print, or undefined if handler didn't match. */
|
|
26
|
+
export type SlashResult = { lines: string[] } | { action: "exit" | "continue" } | undefined;
|
|
27
|
+
|
|
28
|
+
/** Handles /usage command. */
|
|
29
|
+
export function handleUsage(ctx: SlashContext, sessionUsage: { turns: number; inputTokens: number; outputTokens: number }): SlashResult {
|
|
30
|
+
const total = sessionUsage.inputTokens + sessionUsage.outputTokens;
|
|
31
|
+
return {
|
|
32
|
+
lines: [
|
|
33
|
+
"Provider token usage (this REPL):",
|
|
34
|
+
` turns ${sessionUsage.turns}`,
|
|
35
|
+
` input ${sessionUsage.inputTokens}`,
|
|
36
|
+
` output ${sessionUsage.outputTokens}`,
|
|
37
|
+
` total ${total}${total === 0 ? " (providers report usage per turn; run a request first)" : ""}`,
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Handles /tools command. */
|
|
43
|
+
export async function handleTools(ctx: SlashContext): Promise<SlashResult> {
|
|
44
|
+
const lines = ["Tools visible to the agent:"];
|
|
45
|
+
for (const line of TOOL_PROTOCOL.split("\n")) lines.push(` ${line}`);
|
|
46
|
+
lines.push(` ${taskToolProtocolLine(await readGlobalConfig())}`);
|
|
47
|
+
lines.push(` ${TODO_TOOL_PROTOCOL_LINE}`);
|
|
48
|
+
lines.push(` ${SUBAGENT_TOOL_PROTOCOL_LINE}`);
|
|
49
|
+
return { lines };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Handles /hotkeys command. */
|
|
53
|
+
export function handleHotkeys(_ctx: SlashContext): SlashResult {
|
|
54
|
+
return { lines: hotkeysLines() };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Handles /context command. */
|
|
58
|
+
export async function handleContext(ctx: SlashContext): Promise<SlashResult> {
|
|
59
|
+
const { resolved } = await describeModel(ctx.sessionModel || ctx.config.defaultModel);
|
|
60
|
+
const window = catalogMetadata(resolved)?.contextTokens;
|
|
61
|
+
return { lines: contextUsageLines(ctx.history, resolved, window) };
|
|
62
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure presenters for read-only REPL slash commands. These build the exact
|
|
2
|
+
// line arrays the inline handlers used to `console.log` directly, extracted so
|
|
3
|
+
// they can be unit-tested without driving the whole launch REPL. They hold NO
|
|
4
|
+
// mutable session state — every input is passed in explicitly.
|
|
5
|
+
import type { Message } from "../../agent/loop";
|
|
6
|
+
|
|
7
|
+
/** Static keyboard-shortcut reference for `/hotkeys` (no inputs, no state). */
|
|
8
|
+
export function hotkeysLines(): string[] {
|
|
9
|
+
return [
|
|
10
|
+
"Keyboard shortcuts:",
|
|
11
|
+
" Tab complete slash commands, models, roles, @paths",
|
|
12
|
+
" ↑ / ↓ navigate the slash-command preview (Enter runs the highlighted one)",
|
|
13
|
+
" Enter submit input / confirm picker selection",
|
|
14
|
+
" Esc cancel an open picker",
|
|
15
|
+
" Ctrl-C cancel the in-flight turn (press again at the prompt to exit)",
|
|
16
|
+
" Ctrl-D exit the REPL",
|
|
17
|
+
" Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback",
|
|
18
|
+
" Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)",
|
|
19
|
+
" Ctrl-Y / Alt-Y yank / yank-pop the killed text",
|
|
20
|
+
" Ctrl-A / Ctrl-E move to start / end of line",
|
|
21
|
+
" / open the slash-command palette",
|
|
22
|
+
" @path mention a file (Tab completes relative paths)",
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per-role token tallies for `/context`, estimated at ~4 chars/token. Pure over
|
|
27
|
+
* the in-memory history so the math is verifiable in isolation. */
|
|
28
|
+
export function contextUsageLines(
|
|
29
|
+
history: Message[],
|
|
30
|
+
resolved: string,
|
|
31
|
+
window: number | undefined,
|
|
32
|
+
): string[] {
|
|
33
|
+
const est = (s: string) => Math.ceil(s.length / 4);
|
|
34
|
+
const byRole: Record<string, { msgs: number; tokens: number }> = {};
|
|
35
|
+
for (const m of history) {
|
|
36
|
+
const slot = (byRole[m.role] ??= { msgs: 0, tokens: 0 });
|
|
37
|
+
slot.msgs++;
|
|
38
|
+
slot.tokens += est(m.content);
|
|
39
|
+
}
|
|
40
|
+
const total = Object.values(byRole).reduce((sum, r) => sum + r.tokens, 0);
|
|
41
|
+
const lines = ["Context usage (estimated, ~4 chars/token):"];
|
|
42
|
+
for (const [role, r] of Object.entries(byRole)) {
|
|
43
|
+
lines.push(` ${role.padEnd(9)} ${String(r.msgs).padStart(3)} msg${r.msgs === 1 ? " " : "s"} ~${r.tokens} tokens`);
|
|
44
|
+
}
|
|
45
|
+
lines.push(` ${"total".padEnd(9)} ${String(history.length).padStart(3)} msgs ~${total} tokens${window ? ` (${Math.round((total / window) * 100)}% of ${resolved}'s ${window}-token window)` : ""}`);
|
|
46
|
+
lines.push(" Free context with /compact or /clear.");
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -147,6 +147,21 @@ import {
|
|
|
147
147
|
isWorkflowSkill,
|
|
148
148
|
runWorkflowEngine,
|
|
149
149
|
} from "./launch/workflow";
|
|
150
|
+
import {
|
|
151
|
+
mentionPaths as mentionPathsIn,
|
|
152
|
+
currentAtLabel as currentAtLabelFn,
|
|
153
|
+
} from "./launch/mentions";
|
|
154
|
+
import {
|
|
155
|
+
hotkeysLines,
|
|
156
|
+
contextUsageLines,
|
|
157
|
+
} from "./launch/slash-views";
|
|
158
|
+
import {
|
|
159
|
+
handleUsage,
|
|
160
|
+
handleTools,
|
|
161
|
+
handleHotkeys,
|
|
162
|
+
handleContext,
|
|
163
|
+
type SlashContext,
|
|
164
|
+
} from "./launch/slash-handlers";
|
|
150
165
|
|
|
151
166
|
export {
|
|
152
167
|
type LaunchFlags,
|
|
@@ -192,6 +207,8 @@ export {
|
|
|
192
207
|
WORKFLOW_NAMES,
|
|
193
208
|
isWorkflowSkill,
|
|
194
209
|
runWorkflowEngine,
|
|
210
|
+
mentionPathsIn as mentionPaths,
|
|
211
|
+
currentAtLabelFn as currentAtLabel,
|
|
195
212
|
};
|
|
196
213
|
export function normalizeSlashAlias(input: string): string {
|
|
197
214
|
if (input === "/login" || input.startsWith("/login ")) return `/provider login${input.slice("/login".length)}`;
|
|
@@ -1158,31 +1175,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1158
1175
|
liveModelsCache ??= r;
|
|
1159
1176
|
})
|
|
1160
1177
|
.catch(() => {});
|
|
1161
|
-
const mentionPaths = (prefix: string): string[] =>
|
|
1162
|
-
const norm = prefix.replace(/\\/g, "/");
|
|
1163
|
-
const wantsDirChildren = norm.endsWith("/");
|
|
1164
|
-
const dirPart = wantsDirChildren ? norm.slice(0, -1) : path.posix.dirname(norm) === "." ? "" : path.posix.dirname(norm);
|
|
1165
|
-
const namePart = wantsDirChildren ? "" : path.posix.basename(norm);
|
|
1166
|
-
const absDir = path.resolve(cwd, dirPart || ".");
|
|
1167
|
-
let entries: fs.Dirent[] = [];
|
|
1168
|
-
try {
|
|
1169
|
-
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
1170
|
-
} catch {
|
|
1171
|
-
return [];
|
|
1172
|
-
}
|
|
1173
|
-
return entries
|
|
1174
|
-
.filter(entry => !entry.name.startsWith("."))
|
|
1175
|
-
.filter(entry => !namePart || entry.name.toLowerCase().startsWith(namePart.toLowerCase()))
|
|
1176
|
-
.sort((a, b) => {
|
|
1177
|
-
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
1178
|
-
return a.name.localeCompare(b.name);
|
|
1179
|
-
})
|
|
1180
|
-
.slice(0, 50)
|
|
1181
|
-
.map(entry => {
|
|
1182
|
-
const rel = dirPart ? `${dirPart}/${entry.name}` : entry.name;
|
|
1183
|
-
return entry.isDirectory() ? `${rel}/` : rel;
|
|
1184
|
-
});
|
|
1185
|
-
};
|
|
1178
|
+
const mentionPaths = (prefix: string): string[] => mentionPathsIn(cwd, prefix);
|
|
1186
1179
|
const completionContext = (): CompletionContext => {
|
|
1187
1180
|
const base = staticCompletionContext();
|
|
1188
1181
|
return {
|
|
@@ -1512,16 +1505,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1512
1505
|
// turns/command output so the full-screen turn TUI renders normally. The footer
|
|
1513
1506
|
// is drawn at absolute rows (per-row clear → no scroll, no duplication).
|
|
1514
1507
|
// Opt out with JEO_NO_SLASH_PREVIEW=1; auto-off on short terminals.
|
|
1515
|
-
const currentAtLabel = (line: string): string | undefined =>
|
|
1516
|
-
const { tokens } = tokenize(line);
|
|
1517
|
-
const token = [...tokens].reverse().find(t => t.startsWith("@"));
|
|
1518
|
-
if (!token) return undefined;
|
|
1519
|
-
const norm = token.slice(1).replace(/\\/g, "/");
|
|
1520
|
-
if (!norm) return "@ .";
|
|
1521
|
-
if (norm.endsWith("/")) return `@ ${norm.slice(0, -1) || "."}`;
|
|
1522
|
-
const dir = path.posix.dirname(norm);
|
|
1523
|
-
return `@ ${dir === "." ? norm : dir}`;
|
|
1524
|
-
};
|
|
1508
|
+
const currentAtLabel = (line: string): string | undefined => currentAtLabelFn(line);
|
|
1525
1509
|
// Boxed-input footer height — ADAPTIVE so short terminals/panes still get the single
|
|
1526
1510
|
// boxed input instead of silently falling back to the raw `jeo>` prompt (previously
|
|
1527
1511
|
// any terminal under 17 rows lost the box entirely and showed bare CLI input).
|
|
@@ -2893,56 +2877,31 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2893
2877
|
}
|
|
2894
2878
|
// ---- gjc-parity inspection commands ------------------------------------
|
|
2895
2879
|
if (input === "/usage") {
|
|
2896
|
-
const
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
console.log(
|
|
2900
|
-
console.log(` output ${sessionUsage.outputTokens}`);
|
|
2901
|
-
console.log(` total ${total}${total === 0 ? " (providers report usage per turn; run a request first)" : ""}`);
|
|
2880
|
+
const cfg = await readGlobalConfig();
|
|
2881
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2882
|
+
const result = handleUsage(ctx, sessionUsage);
|
|
2883
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2902
2884
|
continue;
|
|
2903
2885
|
}
|
|
2904
2886
|
if (input === "/context") {
|
|
2905
|
-
|
|
2906
|
-
const
|
|
2907
|
-
const
|
|
2908
|
-
for (const
|
|
2909
|
-
const slot = (byRole[m.role] ??= { msgs: 0, tokens: 0 });
|
|
2910
|
-
slot.msgs++;
|
|
2911
|
-
slot.tokens += est(m.content);
|
|
2912
|
-
}
|
|
2913
|
-
const total = Object.values(byRole).reduce((sum, r) => sum + r.tokens, 0);
|
|
2914
|
-
const { resolved } = await describeModel(sessionModel || (await readGlobalConfig()).defaultModel);
|
|
2915
|
-
const window = catalogMetadata(resolved)?.contextTokens;
|
|
2916
|
-
console.log("Context usage (estimated, ~4 chars/token):");
|
|
2917
|
-
for (const [role, r] of Object.entries(byRole)) {
|
|
2918
|
-
console.log(` ${role.padEnd(9)} ${String(r.msgs).padStart(3)} msg${r.msgs === 1 ? " " : "s"} ~${r.tokens} tokens`);
|
|
2919
|
-
}
|
|
2920
|
-
console.log(` ${"total".padEnd(9)} ${String(history.length).padStart(3)} msgs ~${total} tokens${window ? ` (${Math.round((total / window) * 100)}% of ${resolved}'s ${window}-token window)` : ""}`);
|
|
2921
|
-
console.log(" Free context with /compact or /clear.");
|
|
2887
|
+
const cfg = await readGlobalConfig();
|
|
2888
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2889
|
+
const result = await handleContext(ctx);
|
|
2890
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2922
2891
|
continue;
|
|
2923
2892
|
}
|
|
2924
2893
|
if (input === "/tools") {
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
console.log(
|
|
2929
|
-
console.log(` ${SUBAGENT_TOOL_PROTOCOL_LINE}`);
|
|
2894
|
+
const cfg = await readGlobalConfig();
|
|
2895
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2896
|
+
const result = await handleTools(ctx);
|
|
2897
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2930
2898
|
continue;
|
|
2931
2899
|
}
|
|
2932
2900
|
if (input === "/hotkeys") {
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
console.log(" Esc cancel an open picker");
|
|
2938
|
-
console.log(" Ctrl-C cancel the in-flight turn (press again at the prompt to exit)");
|
|
2939
|
-
console.log(" Ctrl-D exit the REPL");
|
|
2940
|
-
console.log(" Ctrl-O dump the full last response (untruncated, tables rendered) into scrollback");
|
|
2941
|
-
console.log(" Ctrl-K / Ctrl-U / Ctrl-W kill to end / start of line / previous word (emacs kill-ring)");
|
|
2942
|
-
console.log(" Ctrl-Y / Alt-Y yank / yank-pop the killed text");
|
|
2943
|
-
console.log(" Ctrl-A / Ctrl-E move to start / end of line");
|
|
2944
|
-
console.log(" / open the slash-command palette");
|
|
2945
|
-
console.log(" @path mention a file (Tab completes relative paths)");
|
|
2901
|
+
const cfg = await readGlobalConfig();
|
|
2902
|
+
const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
|
|
2903
|
+
const result = handleHotkeys(ctx);
|
|
2904
|
+
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
2946
2905
|
continue;
|
|
2947
2906
|
}
|
|
2948
2907
|
if (input === "/theme" || input.startsWith("/theme ")) {
|
|
@@ -185,13 +185,32 @@ export function sanitizeForFrame(s: string): string {
|
|
|
185
185
|
return out;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Active SGR state after applying every SGR escape in `segment` to `prior`.
|
|
189
|
+
* Pragmatic model (matches wrap-ansi): a full reset (`\x1b[0m` / `\x1b[m`) clears
|
|
190
|
+
* the open set; any other SGR is appended. Good enough for the fg/bg/bold coloring
|
|
191
|
+
* a TUI box actually uses; it does not model selective resets (e.g. `\x1b[22m`). */
|
|
192
|
+
function sgrStateAfter(prior: string, segment: string): string {
|
|
193
|
+
if (!segment.includes("\x1b")) return prior;
|
|
194
|
+
let state = prior;
|
|
195
|
+
const re = /\x1b\[[0-9;]*m/g;
|
|
196
|
+
let m: RegExpExecArray | null;
|
|
197
|
+
while ((m = re.exec(segment))) {
|
|
198
|
+
state = m[0] === "\x1b[0m" || m[0] === "\x1b[m" ? "" : state + m[0];
|
|
199
|
+
}
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
/**
|
|
189
204
|
* Hard-wrap text to `cols` display columns, breaking long words and preserving
|
|
190
|
-
* existing newlines. SGR-aware (escapes don't consume width)
|
|
191
|
-
*
|
|
205
|
+
* existing newlines. SGR-aware (escapes don't consume width) AND SGR-stateful:
|
|
206
|
+
* a color opened before a wrap point is RE-APPLIED at the start of each continuation
|
|
207
|
+
* line and CLOSED at its end, so a wrapped colored span stays colored on every row
|
|
208
|
+
* instead of losing its tint after the first line (and never bleeds into the padding
|
|
209
|
+
* or box border). Returns the wrapped lines.
|
|
192
210
|
*/
|
|
193
211
|
export function wrapTextWithAnsi(text: string, cols: number): string[] {
|
|
194
212
|
const width = Math.max(1, cols);
|
|
213
|
+
const RESET = "\x1b[0m";
|
|
195
214
|
const out: string[] = [];
|
|
196
215
|
for (const rawLine of text.split("\n")) {
|
|
197
216
|
if (visibleWidth(rawLine) <= width) {
|
|
@@ -199,16 +218,31 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
|
|
|
199
218
|
continue;
|
|
200
219
|
}
|
|
201
220
|
let rest = rawLine;
|
|
221
|
+
let active = ""; // SGR open at the wrap boundary, carried to the next line
|
|
202
222
|
while (visibleWidth(rest) > width) {
|
|
203
223
|
const head = truncateToWidth(rest, width);
|
|
204
|
-
// Advance past exactly the consumed substring
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
224
|
+
// Advance past exactly the consumed substring. truncateToWidth may append a
|
|
225
|
+
// SYNTHETIC trailing reset (frame safety) that is NOT in `rest` — including it
|
|
226
|
+
// would over-advance and drop real chars. `rest.startsWith(head)` means the
|
|
227
|
+
// reset is genuinely part of the source; otherwise strip the synthetic one.
|
|
228
|
+
const consumed = rest.startsWith(head)
|
|
229
|
+
? head
|
|
230
|
+
: head.endsWith(RESET) && rest.startsWith(head.slice(0, -RESET.length))
|
|
231
|
+
? head.slice(0, -RESET.length)
|
|
232
|
+
: head;
|
|
233
|
+
let line = active + head;
|
|
234
|
+
const next = sgrStateAfter(active, consumed);
|
|
235
|
+
// Close any color still open at the line end so it cannot tint the padding/border.
|
|
236
|
+
if (next && !line.endsWith(RESET)) line += RESET;
|
|
237
|
+
out.push(line);
|
|
238
|
+
active = next;
|
|
209
239
|
rest = rest.slice(consumed.length);
|
|
210
240
|
}
|
|
211
|
-
if (rest.length > 0)
|
|
241
|
+
if (rest.length > 0) {
|
|
242
|
+
let line = active + rest;
|
|
243
|
+
if (active && !line.endsWith(RESET)) line += RESET;
|
|
244
|
+
out.push(line);
|
|
245
|
+
}
|
|
212
246
|
}
|
|
213
247
|
return out;
|
|
214
248
|
}
|