jeo-code 0.6.30 → 0.6.32
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 +27 -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/agent/engine.ts +6 -0
- package/src/agent/session.ts +3 -0
- package/src/ai/model-catalog.ts +4 -5
- package/src/ai/providers/anthropic.ts +95 -16
- package/src/ai/types.ts +5 -0
- package/src/commands/launch.ts +96 -15
- package/src/tui/app.ts +48 -1
- package/src/tui/components/input-box.ts +32 -5
- package/src/tui/components/session-picker.ts +226 -0
- package/src/tui/components/slash.ts +36 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,33 @@ 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.32] - 2026-06-19
|
|
10
|
+
_Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification._
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **Anthropic extended thinking was never turned on — opus-4-8/4-7 reasoning is now actually requested.** The provider parsed and replayed thinking blocks on the *response* side and sent the `interleaved-thinking` beta, but `anthropicPayload` never put a `thinking` parameter in the *request* body, so the API treated every call as non-thinking and (for the internally-reasoning opus-4-7/4-8) returned signature-only/empty thought — reasoning effectively never activated. The request builder now selects a thinking transport per model (`anthropicThinkingMode` via `parseAnthropicVersion` on `claude-<family>-<major>-<minor>`): Anthropic **≥ 4.6 → adaptive** (`thinking: { type: "adaptive" }` with `display: "summarized"` gated to Opus **≥ 4.7** via `supportsAdaptiveThinkingDisplay`, depth riding `output_config.effort`, no `budget_tokens`); **4.5 → budget-effort** (`{ type: "enabled", budget_tokens, display: "summarized" }` + `output_config.effort`); **older → budget** (budget only). jeo's reasoning effort maps to the adaptive/effort literal via `anthropicAdaptiveEffort` (minimal/low/medium/high; xhigh folded to high upstream), `temperature` stays dropped on the thinking path, and the legacy `interleaved-thinking-2025-05-14` beta is filtered out for Opus ≥ 4.7 (`anthropicBetaHeader`) so it can't shadow the adaptive transport. Mirrors gjc's `inferThinkingControlMode` / `supportsAdaptiveThinkingDisplay` behavior.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **The trigger highlight now paints *every* `/command`·`$skill` token on the line and keeps it lit after the space.** New pure helpers in `slash.ts` — `allTriggerTokens(line)` (every whitespace-delimited `/`·`$` word, left-to-right, with code-point `start` offsets; paths like `src/cli` and `FOO$BAR` still excluded) and `committedTriggerToken(line)` (the leading invocation once a space follows, so the highlight no longer vanishes the instant you type a trailing space). `InputBoxOptions.highlight` accepts a multi-range `HighlightRange[]`, so a prompt mentioning several invocations lights each one (valid → neon green, no-match → pink) at once, independent of caret position.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- **`jeo --tmux` has no bun memory leak and stays responsive.** A real `--tmux` session flooded with 200 `/command` keystrokes plus 80 SGR mouse-report sequences via `tmux send-keys` holds RSS bounded (159.8 → 161.5 MB peak → 161.4 MB settled, +1.5 MB and *decreasing* after the flood — no per-event linear growth) and the `tmux-verify.sh smoke` + `battery` (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`) all pass.
|
|
20
|
+
- **Full suite green:** `bun run typecheck` clean and `bun test` 1708 pass / 0 fail across 211 files (includes the extended `test/anthropic-stream.test.ts` adaptive/budget request-body coverage and the `test/slash.test.ts` / `test/input-box.test.ts` multi-token highlight tests).
|
|
21
|
+
|
|
22
|
+
## [0.6.31] - 2026-06-19
|
|
23
|
+
_Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification._
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- **The prompt box now recolors the `/command` / `$skill` trigger token live as you type it.** While typing an invocation, the active trigger token (anywhere on the line, mention-style via `activeTriggerToken`) is repainted inside the input box so the user can SEE the trigger was recognized: a valid, matchable invocation turns neon green (`#39ff14`), while a typo with no match turns pink (`#ff6b81`) — a visual heads-up that it will be sent as plain text. Wired through a new `InputBoxOptions.highlight` ({start,end,paint}, code-point offsets over `Array.from(line)`) into both the idle prompt (`launch.ts` `previewLines`) and the mid-turn live box (`app.ts` `setLivePromptHighlight`, reset at each new turn). Scroll ellipses now use ANSI-safe `truncateToWidth` so a painted token never gets sliced mid-escape.
|
|
27
|
+
- **Rich `/resume` session picker (gjc parity).** A new `src/tui/components/session-picker.ts` renders a search/filter line, a scrolling window of multi-line entries (title + dimmed first-message preview + a `relative-time · size · N msgs` metadata line), a position indicator, and Del-to-delete / Enter-to-resume / Esc-to-cancel hints. `SessionSummary` now carries `sizeBytes` for the metadata line.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
- **Signature-only reasoning models now show a live Thinking block while the model thinks.** Models that reason internally and stream a `signature` but NO `thinking_delta` text (claude-opus-4-7/4-8) opened a thinking block that produced zero visible deltas, so the TUI's dimmed live "Thinking" trace never appeared — the response wait read as a frozen "calling model …". The Anthropic stream adapter now fires a new display-only `onReasoningStart` signal the instant a `thinking` / `redacted_thinking` block opens, and the TUI renders a live `Thinking · Ns` block with a `(thinking…)` placeholder that is replaced the moment any real thought or answer text streams. Replay/artifact capture is unchanged.
|
|
31
|
+
|
|
32
|
+
### Verified
|
|
33
|
+
- **`jeo --tmux` has no bun memory leak and stays responsive.** A real `--tmux` session flooded with ~30,000 SGR mouse-report sequences via `tmux send-keys` plateaus in RSS (147 → 246 MB asymptotically: +83 / +12 / +3 / +0.2 / +0.4 MB per 6k-report round → no per-event linear growth) and stays responsive afterward (`/model` preview renders in 14 ms with the trigger highlight intact). The mouse-report swallow guard drops the reports instead of buffering/echoing them.
|
|
34
|
+
- **Full suite green:** `bun run typecheck` clean and `bun test` 1703 pass / 0 fail across 211 files (includes the new `test/input-box.test.ts`, `test/tui-app.test.ts`, and `test/session-picker.test.ts` highlight/picker coverage).
|
|
35
|
+
|
|
9
36
|
## [0.6.30] - 2026-06-19
|
|
10
37
|
_gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down._
|
|
11
38
|
|
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.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
|
|
204
|
+
- **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
|
|
203
205
|
- **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
|
|
204
206
|
- **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
|
|
205
207
|
- **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
|
|
206
|
-
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
207
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
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.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
|
|
204
|
+
- **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
|
|
203
205
|
- **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
|
|
204
206
|
- **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
|
|
205
207
|
- **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
|
|
206
|
-
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
207
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
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.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
|
|
204
|
+
- **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
|
|
203
205
|
- **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
|
|
204
206
|
- **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
|
|
205
207
|
- **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
|
|
206
|
-
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
207
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
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.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
|
|
204
|
+
- **[0.6.31]** (2026-06-19) — Live "Thinking" indicator for signature-only reasoning models (Anthropic opus-4-7/4-8), a live color cue when a `/command` or `$skill` trigger is recognized in the prompt, and a rich gjc-style `/resume` session picker — plus a fresh `jeo --tmux` no-leak re-verification.
|
|
203
205
|
- **[0.6.30]** (2026-06-19) — gjc-style intermediate-judgment guard classification extracted from the engine loop, plus a re-verification that `jeo --tmux` does not leak bun memory or slow down.
|
|
204
206
|
- **[0.6.29]** (2026-06-19) — Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak.
|
|
205
207
|
- **[0.6.28]** (2026-06-19) — Signed thinking-block replay: native reasoning is now sent BACK to providers across steps/turns, restoring multi-step reasoning continuity (gajae parity).
|
|
206
|
-
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
207
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
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
|
@@ -35,6 +35,7 @@ async function invokeCallLlm(history: Message[], options: {
|
|
|
35
35
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
36
36
|
onToken?: (delta: string) => void;
|
|
37
37
|
onReasoning?: (delta: string) => void;
|
|
38
|
+
onReasoningStart?: () => void;
|
|
38
39
|
onReasoningArtifact?: (artifact: import("../ai/types").ReasoningArtifact) => void;
|
|
39
40
|
tools?: import("../ai/types").NativeToolSchema[];
|
|
40
41
|
}): Promise<string> {
|
|
@@ -196,6 +197,10 @@ export interface AgentLoopEvents {
|
|
|
196
197
|
/** Accumulated native reasoning/thinking text so far — drives a transient dimmed
|
|
197
198
|
* "thinking" view. Only requested when a consumer (TUI) attaches. */
|
|
198
199
|
onReasoningStream?(textSoFar: string): void;
|
|
200
|
+
/** Fired once when the model opens an extended-thinking block (before/without any
|
|
201
|
+
* thinking text). Lets the TUI show a live "thinking" indicator for signature-only
|
|
202
|
+
* reasoning models (opus-4-7/4-8) whose wait would otherwise look frozen. */
|
|
203
|
+
onReasoningStart?(): void;
|
|
199
204
|
/** Each provider-native reasoning ARTIFACT as it is captured (signature / thoughtSignature /
|
|
200
205
|
* reasoning item). Lets the final-reply path (launch.ts) persist artifacts for replay. */
|
|
201
206
|
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
@@ -526,6 +531,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
526
531
|
onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
|
|
527
532
|
onToken,
|
|
528
533
|
onReasoning,
|
|
534
|
+
onReasoningStart: ev.onReasoningStart,
|
|
529
535
|
onReasoningArtifact,
|
|
530
536
|
// Make provider auto-retry visible: previously a rate-limited call sat in a
|
|
531
537
|
// silent backoff wait, then surfaced "auto-retry was exhausted" with no trace
|
package/src/agent/session.ts
CHANGED
|
@@ -32,6 +32,8 @@ export interface SessionSummary {
|
|
|
32
32
|
messageCount: number;
|
|
33
33
|
preview: string;
|
|
34
34
|
mtimeMs?: number;
|
|
35
|
+
/** Session file size in bytes (for the resume picker's metadata line). */
|
|
36
|
+
sizeBytes?: number;
|
|
35
37
|
title?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
@@ -288,6 +290,7 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
|
|
|
288
290
|
messageCount,
|
|
289
291
|
preview,
|
|
290
292
|
mtimeMs: stat.mtimeMs,
|
|
293
|
+
sizeBytes: stat.size,
|
|
291
294
|
title: header.title,
|
|
292
295
|
});
|
|
293
296
|
} catch {
|
package/src/ai/model-catalog.ts
CHANGED
|
@@ -65,11 +65,10 @@ export const MODEL_CATALOG: readonly CatalogModel[] = [
|
|
|
65
65
|
{ canonical: "claude-sonnet-4-5", provider: "anthropic", providerModel: "claude-sonnet-4-5-20250929", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
|
|
66
66
|
{ canonical: "claude-opus-4-1", provider: "anthropic", providerModel: "claude-opus-4-1-20250805", contextTokens: 200_000, maxOutputTokens: 32_000, thinking: FULL, images: true },
|
|
67
67
|
{ canonical: "claude-opus-4-5", provider: "anthropic", providerModel: "claude-opus-4-5-20251101", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
|
|
68
|
-
// NOTE: opus
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// artifacts for cross-turn continuity.
|
|
68
|
+
// NOTE: opus 4.6+ use Anthropic ADAPTIVE thinking (type:"adaptive" + output_config.effort).
|
|
69
|
+
// opus 4.7/4.8 OMIT visible thought unless the request opts into `display: "summarized"` —
|
|
70
|
+
// anthropic.ts sets that on the adaptive transport so reasoning streams again (gjc parity).
|
|
71
|
+
// The nativizable path still replays signature-only thinking blocks for cross-turn continuity.
|
|
73
72
|
{ canonical: "claude-opus-4-6", provider: "anthropic", providerModel: "claude-opus-4-6", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
|
|
74
73
|
{ canonical: "claude-opus-4-7", provider: "anthropic", providerModel: "claude-opus-4-7", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
|
|
75
74
|
{ canonical: "claude-opus-4-8", provider: "anthropic", providerModel: "claude-opus-4-8", contextTokens: 200_000, maxOutputTokens: 64_000, thinking: FULL, images: true },
|
|
@@ -16,14 +16,25 @@ const CLAUDE_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
|
16
16
|
const ANTHROPIC_API_KEY_BETA = [
|
|
17
17
|
"interleaved-thinking-2025-05-14",
|
|
18
18
|
"prompt-caching-scope-2026-01-05",
|
|
19
|
-
]
|
|
19
|
+
];
|
|
20
20
|
const ANTHROPIC_OAUTH_BETA = [
|
|
21
21
|
"claude-code-20250219",
|
|
22
22
|
"oauth-2025-04-20",
|
|
23
23
|
"interleaved-thinking-2025-05-14",
|
|
24
24
|
"context-management-2025-06-27",
|
|
25
25
|
"prompt-caching-scope-2026-01-05",
|
|
26
|
-
]
|
|
26
|
+
];
|
|
27
|
+
const INTERLEAVED_THINKING_BETA = "interleaved-thinking-2025-05-14";
|
|
28
|
+
|
|
29
|
+
/** The interleaved-thinking beta drives BUDGET-based thinking+tools. Adaptive-display models
|
|
30
|
+
* (Opus 4.7+) use adaptive thinking and DON'T need it — gjc drops it for these so the legacy
|
|
31
|
+
* beta doesn't shadow the adaptive transport. */
|
|
32
|
+
function anthropicBetaHeader(betas: string[], model: string): string {
|
|
33
|
+
const filtered = supportsAdaptiveThinkingDisplay(model)
|
|
34
|
+
? betas.filter(b => b !== INTERLEAVED_THINKING_BETA)
|
|
35
|
+
: betas;
|
|
36
|
+
return filtered.join(",");
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
interface AnthropicSystemBlock {
|
|
29
40
|
type: "text";
|
|
@@ -94,6 +105,51 @@ function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"], maxToke
|
|
|
94
105
|
return Math.min(budget, Math.max(1024, maxTokens - 1024));
|
|
95
106
|
}
|
|
96
107
|
|
|
108
|
+
/** Parse an Anthropic model id's family + version for thinking-transport selection.
|
|
109
|
+
* Matches the modern `claude-<family>-<major>-<minor>[...]` naming (opus/sonnet/haiku 4.x+);
|
|
110
|
+
* legacy ids (claude-3-5-sonnet) and non-Anthropic-compatible names return undefined. */
|
|
111
|
+
function parseAnthropicVersion(model: string): { kind: "opus" | "sonnet" | "haiku"; major: number; minor: number } | undefined {
|
|
112
|
+
const m = /claude-(opus|sonnet|haiku)-(\d+)-(\d+)/.exec(model);
|
|
113
|
+
if (!m) return undefined;
|
|
114
|
+
return { kind: m[1] as "opus" | "sonnet" | "haiku", major: Number(m[2]), minor: Number(m[3]) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Adaptive thinking `display` is supported starting with Opus 4.7. Without it, Opus 4.7/4.8
|
|
118
|
+
* OMIT thinking content entirely (tokens billed, signature present, but zero visible thought —
|
|
119
|
+
* the "reasoning doesn't show" bug). Older adaptive models (Opus 4.6, Sonnet 4.6+) reject the
|
|
120
|
+
* field, so it is gated to Opus ≥ 4.7. (gjc: supportsAdaptiveThinkingDisplay) */
|
|
121
|
+
function supportsAdaptiveThinkingDisplay(model: string): boolean {
|
|
122
|
+
const v = parseAnthropicVersion(model);
|
|
123
|
+
if (!v || v.kind !== "opus") return false;
|
|
124
|
+
return v.major > 4 || (v.major === 4 && v.minor >= 7);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Thinking transport for a model (gjc parity — inferThinkingControlMode):
|
|
128
|
+
* - Anthropic ≥ 4.6 → "adaptive" (model decides depth; effort rides output_config, NO budget)
|
|
129
|
+
* - Anthropic 4.5 → "budget-effort" (budget_tokens + output_config effort)
|
|
130
|
+
* - otherwise → "budget" (budget_tokens only).
|
|
131
|
+
* The adaptive shift is the core opus-4.7/4.8 reasoning fix: those models reject the legacy
|
|
132
|
+
* budget transport's visible-thought contract and require type:"adaptive" + display:summarized. */
|
|
133
|
+
type AnthropicThinkingMode = "adaptive" | "budget-effort" | "budget";
|
|
134
|
+
function anthropicThinkingMode(model: string): AnthropicThinkingMode {
|
|
135
|
+
const v = parseAnthropicVersion(model);
|
|
136
|
+
if (!v) return "budget";
|
|
137
|
+
if (v.major > 4 || (v.major === 4 && v.minor >= 6)) return "adaptive";
|
|
138
|
+
if (v.major === 4 && v.minor === 5) return "budget-effort";
|
|
139
|
+
return "budget";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Map jeo's reasoning effort to Anthropic's adaptive/output_config effort literal. jeo folds
|
|
143
|
+
* xhigh→high upstream, so only minimal/low/medium/high arrive here. (gjc: mapEffortToAnthropicAdaptiveEffort) */
|
|
144
|
+
function anthropicAdaptiveEffort(effort: NonNullable<CallOptions["reasoningEffort"]>): "low" | "medium" | "high" {
|
|
145
|
+
switch (effort) {
|
|
146
|
+
case "minimal":
|
|
147
|
+
case "low": return "low";
|
|
148
|
+
case "medium": return "medium";
|
|
149
|
+
case "high": return "high";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
97
153
|
type AnthropicContentBlock = Record<string, unknown>;
|
|
98
154
|
type AnthropicMessage = { role: string; content: string | AnthropicContentBlock[] };
|
|
99
155
|
|
|
@@ -160,10 +216,17 @@ export function anthropicPayload(
|
|
|
160
216
|
const systemPrompt = options.systemPrompt ?? messages.find(m => m.role === "system")?.content;
|
|
161
217
|
// Image attachments + native tool/thinking-block reconstruction live in buildAnthropicMessages.
|
|
162
218
|
const maxTokens = options.maxTokens ?? 4000;
|
|
163
|
-
const
|
|
219
|
+
const effort = options.reasoningEffort;
|
|
220
|
+
const thinkingEnabled = effort !== undefined;
|
|
221
|
+
// gjc parity: pick the thinking transport per model. Adaptive (Opus/Sonnet 4.6+) carries NO
|
|
222
|
+
// budget_tokens — depth rides output_config.effort. budget/budget-effort still use a budget.
|
|
223
|
+
const thinkingMode = thinkingEnabled ? anthropicThinkingMode(model) : "budget";
|
|
224
|
+
const thinkingBudget = thinkingEnabled && thinkingMode !== "adaptive"
|
|
225
|
+
? anthropicThinkingBudget(effort, maxTokens)
|
|
226
|
+
: undefined;
|
|
164
227
|
// Reconstruct native tool_use / tool_result / thinking blocks for same-model turns when
|
|
165
228
|
// thinking is enabled (and not stripped by a fail-safe retry); else plain string/image.
|
|
166
|
-
const anthropicMessages = buildAnthropicMessages(messages, options.model,
|
|
229
|
+
const anthropicMessages = buildAnthropicMessages(messages, options.model, thinkingEnabled && !stripArtifacts);
|
|
167
230
|
// Conversation prompt caching (gjc parity — the main same-model latency gap):
|
|
168
231
|
// one breakpoint on the LAST message caches the entire conversation prefix, so
|
|
169
232
|
// each agent-loop step only pays input processing for the new tail instead of
|
|
@@ -187,12 +250,24 @@ export function anthropicPayload(
|
|
|
187
250
|
max_tokens: thinkingBudget !== undefined ? Math.max(maxTokens, thinkingBudget + 1024) : maxTokens,
|
|
188
251
|
};
|
|
189
252
|
if (credential.kind === "oauth") payload.metadata = { user_id: createClaudeCloakingUserId() };
|
|
190
|
-
if (
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
253
|
+
if (effort !== undefined) {
|
|
254
|
+
// Enable Claude extended thinking. Extended thinking forbids a custom temperature, so
|
|
255
|
+
// temperature is only set on the non-thinking path.
|
|
256
|
+
if (thinkingMode === "adaptive") {
|
|
257
|
+
// Opus/Sonnet 4.6+: the model decides how much to think. `display: "summarized"` is
|
|
258
|
+
// REQUIRED on Opus 4.7+ or thinking content is omitted from the response (the empty-thought
|
|
259
|
+
// bug); older adaptive models (4.6) reject the field, so it is gated. Effort rides
|
|
260
|
+
// output_config — there is no budget_tokens on this transport.
|
|
261
|
+
payload.thinking = supportsAdaptiveThinkingDisplay(model)
|
|
262
|
+
? { type: "adaptive", display: "summarized" }
|
|
263
|
+
: { type: "adaptive" };
|
|
264
|
+
payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
|
|
265
|
+
} else {
|
|
266
|
+
// Budget-based extended thinking. `display: "summarized"` keeps human-readable thought
|
|
267
|
+
// streaming. The 4.5 (budget-effort) transport also carries an output_config effort.
|
|
268
|
+
payload.thinking = { type: "enabled", budget_tokens: thinkingBudget, display: "summarized" };
|
|
269
|
+
if (thinkingMode === "budget-effort") payload.output_config = { effort: anthropicAdaptiveEffort(effort) };
|
|
270
|
+
}
|
|
196
271
|
} else if (includeTemperature && options.temperature !== undefined) {
|
|
197
272
|
payload.temperature = options.temperature;
|
|
198
273
|
}
|
|
@@ -221,7 +296,7 @@ export function anthropicRequest(
|
|
|
221
296
|
// Anthropic-compatible providers (z.ai, MiniMax, …) accept the Messages wire
|
|
222
297
|
// format at their own host; an explicit baseUrl pins `${base}/v1/messages`.
|
|
223
298
|
url: options.baseUrl ? `${options.baseUrl.replace(/\/$/, "")}/v1/messages` : ANTHROPIC_URL,
|
|
224
|
-
headers: headersFor(credential, stream),
|
|
299
|
+
headers: headersFor(credential, stream, stripAnthropicPrefix(options.model)),
|
|
225
300
|
body: anthropicPayload(messages, options, stream, includeTemperature, credential, stripArtifacts),
|
|
226
301
|
};
|
|
227
302
|
}
|
|
@@ -353,8 +428,12 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
353
428
|
toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
|
|
354
429
|
} else if (evt.type === "content_block_start" && evt.content_block?.type === "thinking" && typeof evt.index === "number") {
|
|
355
430
|
thinkBlocks.set(evt.index, { text: "" });
|
|
431
|
+
// Signal the thinking phase started so the UI shows a live "thinking" indicator
|
|
432
|
+
// even for signature-only models (opus-4-7/4-8) that stream NO thinking_delta text.
|
|
433
|
+
options.onReasoningStart?.();
|
|
356
434
|
} else if (evt.type === "content_block_start" && evt.content_block?.type === "redacted_thinking" && evt.content_block.data) {
|
|
357
435
|
// Redacted thinking carries opaque `data` directly (no deltas) — emit immediately.
|
|
436
|
+
options.onReasoningStart?.();
|
|
358
437
|
options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, redacted: evt.content_block.data });
|
|
359
438
|
} else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
|
|
360
439
|
const b = toolBlocks.get(evt.index);
|
|
@@ -427,10 +506,10 @@ function mapStainlessArch(arch: string): "x64" | "arm64" | "x86" | `other::${str
|
|
|
427
506
|
}
|
|
428
507
|
}
|
|
429
508
|
|
|
430
|
-
function claudeCodeOAuthHeaders(stream: boolean): Record<string, string> {
|
|
509
|
+
function claudeCodeOAuthHeaders(stream: boolean, model: string): Record<string, string> {
|
|
431
510
|
return {
|
|
432
511
|
accept: stream ? "text/event-stream" : "application/json",
|
|
433
|
-
"anthropic-beta": ANTHROPIC_OAUTH_BETA,
|
|
512
|
+
"anthropic-beta": anthropicBetaHeader(ANTHROPIC_OAUTH_BETA, model),
|
|
434
513
|
"anthropic-dangerous-direct-browser-access": "true",
|
|
435
514
|
"user-agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
|
|
436
515
|
"x-app": "cli",
|
|
@@ -445,13 +524,13 @@ function claudeCodeOAuthHeaders(stream: boolean): Record<string, string> {
|
|
|
445
524
|
};
|
|
446
525
|
}
|
|
447
526
|
|
|
448
|
-
function headersFor(credential: Credential, stream: boolean): Record<string, string> {
|
|
527
|
+
function headersFor(credential: Credential, stream: boolean, model: string): Record<string, string> {
|
|
449
528
|
if (credential.kind === "oauth") {
|
|
450
529
|
return {
|
|
451
530
|
"content-type": "application/json",
|
|
452
531
|
authorization: `Bearer ${credential.token}`,
|
|
453
532
|
"anthropic-version": "2023-06-01",
|
|
454
|
-
...claudeCodeOAuthHeaders(stream),
|
|
533
|
+
...claudeCodeOAuthHeaders(stream, model),
|
|
455
534
|
};
|
|
456
535
|
}
|
|
457
536
|
if (credential.kind === "api_key") {
|
|
@@ -460,7 +539,7 @@ function headersFor(credential: Credential, stream: boolean): Record<string, str
|
|
|
460
539
|
"content-type": "application/json",
|
|
461
540
|
"x-api-key": credential.token,
|
|
462
541
|
"anthropic-version": "2023-06-01",
|
|
463
|
-
"anthropic-beta": ANTHROPIC_API_KEY_BETA,
|
|
542
|
+
"anthropic-beta": anthropicBetaHeader(ANTHROPIC_API_KEY_BETA, model),
|
|
464
543
|
};
|
|
465
544
|
}
|
|
466
545
|
throw new Error("anthropic adapter requires a credential");
|
package/src/ai/types.ts
CHANGED
|
@@ -116,6 +116,11 @@ export interface CallOptions {
|
|
|
116
116
|
* answer text). Surfaced as a transient dimmed view; absent for models that emit no
|
|
117
117
|
* thought text. */
|
|
118
118
|
onReasoning?: (delta: string) => void;
|
|
119
|
+
/** Fired ONCE when the model opens an extended-thinking block, before (or without) any
|
|
120
|
+
* thinking-text deltas. Lets a UI show a live "thinking" indicator even for models
|
|
121
|
+
* (e.g. claude-opus-4-7/4-8) that reason internally and stream NO visible thought text,
|
|
122
|
+
* so the response wait does not look frozen. Display-only — carries no content. */
|
|
123
|
+
onReasoningStart?: () => void;
|
|
119
124
|
/** Sink for provider-native reasoning ARTIFACTS captured during streaming (signature /
|
|
120
125
|
* thoughtSignature / reasoning item id+encrypted). Separate from `onReasoning` (display
|
|
121
126
|
* text) because these arrive on different SSE events and are opaque replay data. */
|
package/src/commands/launch.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { formatForgeBox } from "../tui/components/forge";
|
|
|
19
19
|
import { interactiveOAuthLogin } from "./auth";
|
|
20
20
|
import { logoutOAuth, OAUTH_PROVIDERS, API_KEY_ONLY_PROVIDERS, setApiKey } from "../auth";
|
|
21
21
|
import type { AuthProvider } from "../auth";
|
|
22
|
-
import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
|
|
22
|
+
import { matchSlash, isSlashAttempt, suggestSlashCommands, formatSlashCommandList, formatSlashPreview, slashPreviewMatches, activeTriggerToken, allTriggerTokens, tabCompleteSelection, type SlashCommandInfo } from "../tui/components/slash";
|
|
23
23
|
import { staticCompletionContext, readlineCompleter, formatCompletionPreview, formatMidTurnHint, tokenize, type CompletionContext } from "../tui/components/autocomplete";
|
|
24
24
|
import { normalizeBaseUrl } from "./setup-helpers";
|
|
25
25
|
import { EVOLUTION_STAGES, animateAsciiArt } from "../tui/components/ascii-art";
|
|
@@ -45,6 +45,7 @@ import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/op
|
|
|
45
45
|
|
|
46
46
|
import { allSubagentRoles, getSubagentRole, resolveSubagentModel, resolveSubagentMaxSteps, resolveSubagentThinking, parseMaxSteps, withSubagentSetting, clearSubagentSetting } from "../agent/subagents";
|
|
47
47
|
import { SelectList, renderSelectList, type SelectItem } from "../tui/components/select-list";
|
|
48
|
+
import { SessionPicker, renderSessionPicker } from "../tui/components/session-picker";
|
|
48
49
|
import {
|
|
49
50
|
formatModelLine,
|
|
50
51
|
formatProviderPanel,
|
|
@@ -61,7 +62,7 @@ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } fro
|
|
|
61
62
|
import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
|
|
62
63
|
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
|
|
63
64
|
import { categoryBadge } from "../tui/components/category-index";
|
|
64
|
-
import { renderInputFrame, verticalCursorOffset } from "../tui/components/input-box";
|
|
65
|
+
import { renderInputFrame, verticalCursorOffset, type HighlightRange } from "../tui/components/input-box";
|
|
65
66
|
|
|
66
67
|
import { renderStatusBar } from "../tui/components/status";
|
|
67
68
|
import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
|
|
@@ -760,6 +761,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
760
761
|
queueBusyClear?.();
|
|
761
762
|
tui.setLivePromptInput("");
|
|
762
763
|
tui.setLivePromptHint([]);
|
|
764
|
+
tui.setLivePromptHighlight(undefined);
|
|
763
765
|
if (classifyMidTurnLine(line) === "command") {
|
|
764
766
|
// Run it as a real COMMAND: queue it for immediate dispatch by the prompt
|
|
765
767
|
// loop and abort the turn (the same controller Esc uses). The abort ends a
|
|
@@ -798,6 +800,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
798
800
|
tui.setLivePromptHint(
|
|
799
801
|
/^\s*[/$]/.test(draft) ? formatMidTurnHint(draft.trimStart(), completionContext(), 5) : [],
|
|
800
802
|
);
|
|
803
|
+
tui.setLivePromptHighlight(triggerHighlight(expandSentinel(draft)));
|
|
801
804
|
}
|
|
802
805
|
},
|
|
803
806
|
onAbortNotice: msg => {
|
|
@@ -1790,6 +1793,37 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1790
1793
|
if (sessionId) { const hex = SESSION_BOX_ACCENTS[sessionBoxColorIdx]!; return { accent: hexPaint(hex), shadow: hexShadowPaint(hex) }; }
|
|
1791
1794
|
return { accent: uiAccent, shadow: uiAccentShadow };
|
|
1792
1795
|
};
|
|
1796
|
+
// Recolor the active `/command` or `$skill` trigger token INSIDE the input box so a
|
|
1797
|
+
// real invocation is visibly recognized as it is typed: neon green once the token
|
|
1798
|
+
// resolves to ≥1 command/skill, caution pink while it matches none (a likely typo
|
|
1799
|
+
// that would be sent as plain text). Offsets are code-point indices into the SAME
|
|
1800
|
+
// string the box renders, so multi-byte preceding text stays aligned with the box's
|
|
1801
|
+
// Array.from() char model. Returns undefined for colorless themes / no active trigger.
|
|
1802
|
+
const TRIGGER_HL_VALID = "#39ff14";
|
|
1803
|
+
const TRIGGER_HL_UNKNOWN = "#ff6b81";
|
|
1804
|
+
const triggerHighlight = (
|
|
1805
|
+
rendered: string,
|
|
1806
|
+
): HighlightRange[] => {
|
|
1807
|
+
if (!uiTheme.color) return [];
|
|
1808
|
+
// Highlight EVERY `/command`·`$skill` invocation in the line at once and
|
|
1809
|
+
// independent of caret position, so multiple triggers all stay lit and a
|
|
1810
|
+
// token keeps its color even when the caret jumps elsewhere to edit.
|
|
1811
|
+
const out: HighlightRange[] = [];
|
|
1812
|
+
for (const trigger of allTriggerTokens(rendered)) {
|
|
1813
|
+
const start = Array.from(rendered.slice(0, trigger.start)).length;
|
|
1814
|
+
const end = start + Array.from(trigger.token).length;
|
|
1815
|
+
// "Open" = the word still being typed: it reaches the end of the line with
|
|
1816
|
+
// no space after it. An open token counts as valid-so-far on any match
|
|
1817
|
+
// (incl. fuzzy prefix); every committed token must be an EXACT known
|
|
1818
|
+
// command/skill to stay green, else it shows caution pink (likely typo).
|
|
1819
|
+
const isOpen = trigger.start + trigger.token.length === rendered.length;
|
|
1820
|
+
const matches = slashPreviewMatches(trigger.token, skillSlashDetails, resolvedSkills);
|
|
1821
|
+
const valid = isOpen ? matches.length > 0 : matches.includes(trigger.token);
|
|
1822
|
+
const hex = valid ? TRIGGER_HL_VALID : TRIGGER_HL_UNKNOWN;
|
|
1823
|
+
out.push({ start, end, paint: (s: string) => chalk.hex(hex)(s) });
|
|
1824
|
+
}
|
|
1825
|
+
return out;
|
|
1826
|
+
};
|
|
1793
1827
|
const refreshUiTheme = (): void => {
|
|
1794
1828
|
uiTheme = resolveTheme(process.env);
|
|
1795
1829
|
uiAccent = accentPaint(uiTheme);
|
|
@@ -1825,7 +1859,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1825
1859
|
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
1826
1860
|
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
1827
1861
|
const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
|
|
1828
|
-
const
|
|
1862
|
+
const rendered = expandSentinel(line);
|
|
1863
|
+
const frame = renderInputFrame(rendered, {
|
|
1829
1864
|
// Full terminal width (cols is already columns - 1, leaving the last column free
|
|
1830
1865
|
// so a full-width row never wraps). Matches the live-turn box, user/forge cards,
|
|
1831
1866
|
// and the welcome banner — all share this cols-1 width so nothing jumps on the
|
|
@@ -1841,6 +1876,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1841
1876
|
: undefined,
|
|
1842
1877
|
maxBodyRows: Math.max(1, footerRows - 7),
|
|
1843
1878
|
cursor: caret,
|
|
1879
|
+
highlight: triggerHighlight(rendered),
|
|
1844
1880
|
});
|
|
1845
1881
|
const input = frame.lines.map(l => truncateAnsi(l, cols));
|
|
1846
1882
|
// jeo-ref layout: a blank spacer row between the status bar (row 0) and the
|
|
@@ -2930,26 +2966,71 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2930
2966
|
if (arg) { await applyResume(arg); continue; }
|
|
2931
2967
|
// No id → only sessions with a real conversation are resumable (every launch
|
|
2932
2968
|
// creates an empty session; those are noise).
|
|
2933
|
-
|
|
2934
|
-
if (
|
|
2969
|
+
let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
|
|
2970
|
+
if (pool.length === 0) {
|
|
2935
2971
|
console.log("(no saved sessions with history)");
|
|
2936
2972
|
continue;
|
|
2937
2973
|
}
|
|
2938
|
-
// Interactive
|
|
2974
|
+
// Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
|
|
2975
|
+
// move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
|
|
2939
2976
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2977
|
+
// Loop so a delete refreshes the list and re-opens the picker in place.
|
|
2978
|
+
for (;;) {
|
|
2979
|
+
const picker = new SessionPicker(pool);
|
|
2980
|
+
let action: { kind: "resume" | "delete"; id: string } | undefined;
|
|
2981
|
+
let confirmDeleteId: string | undefined;
|
|
2982
|
+
await runSelectPicker(
|
|
2983
|
+
(cols, rows) => renderSessionPicker(picker, {
|
|
2984
|
+
title: "Resume a session",
|
|
2985
|
+
cols,
|
|
2986
|
+
rows: Math.max(8, rows),
|
|
2987
|
+
unicode: true,
|
|
2988
|
+
color: true,
|
|
2989
|
+
confirmDeleteId,
|
|
2990
|
+
}),
|
|
2991
|
+
(ch, key) => {
|
|
2992
|
+
if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
|
|
2993
|
+
if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
|
|
2994
|
+
if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
|
|
2995
|
+
if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
|
|
2996
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
|
|
2997
|
+
if (key?.name === "delete") {
|
|
2998
|
+
const sel = picker.selected();
|
|
2999
|
+
if (!sel) return false;
|
|
3000
|
+
if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
|
|
3001
|
+
confirmDeleteId = sel.id;
|
|
3002
|
+
return false;
|
|
3003
|
+
}
|
|
3004
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
3005
|
+
const sel = picker.selected();
|
|
3006
|
+
if (sel) { action = { kind: "resume", id: sel.id }; return true; }
|
|
3007
|
+
return false;
|
|
3008
|
+
}
|
|
3009
|
+
confirmDeleteId = undefined;
|
|
3010
|
+
if (key?.name === "backspace") { picker.backspace(); return false; }
|
|
3011
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
|
|
3012
|
+
return false;
|
|
3013
|
+
},
|
|
3014
|
+
);
|
|
3015
|
+
if (!action) { console.log("(resume cancelled)"); break; }
|
|
3016
|
+
if (action.kind === "resume") { await applyResume(action.id); break; }
|
|
3017
|
+
// Delete: drop the file, refresh the pool, and re-open the picker.
|
|
3018
|
+
const delId = action.id;
|
|
3019
|
+
try {
|
|
3020
|
+
const removed = await deleteSession(delId, cwd);
|
|
3021
|
+
console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
|
|
3022
|
+
} catch (err) {
|
|
3023
|
+
console.log(`! delete failed: ${(err as Error).message}`);
|
|
3024
|
+
}
|
|
3025
|
+
if (delId === sessionId) await startFreshSession("dropped current session");
|
|
3026
|
+
pool = pool.filter(s => s.id !== delId);
|
|
3027
|
+
if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
|
|
3028
|
+
}
|
|
2948
3029
|
continue;
|
|
2949
3030
|
}
|
|
2950
3031
|
// Non-TTY fallback: static list (resume with /session resume <id>).
|
|
2951
3032
|
console.log("Saved sessions — resume with /session resume <id>:");
|
|
2952
|
-
for (const s of
|
|
3033
|
+
for (const s of pool.slice(0, 15)) {
|
|
2953
3034
|
const marker = s.id === sessionId ? "*" : " ";
|
|
2954
3035
|
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
|
|
2955
3036
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -37,10 +37,18 @@ import { formatHintBar } from "./components/hints";
|
|
|
37
37
|
import { formatDuration, formatUsage } from "./components/duration";
|
|
38
38
|
import { renderHud, type JeoPhase } from "./components/hud";
|
|
39
39
|
import { formatTodoWriteCard } from "./components/todo-card";
|
|
40
|
-
import { renderInputBox } from "./components/input-box";
|
|
40
|
+
import { renderInputBox, type HighlightRange } from "./components/input-box";
|
|
41
41
|
import { jeoEnv } from "../util/env";
|
|
42
42
|
import chalk from "chalk";
|
|
43
43
|
|
|
44
|
+
/** Stable signature of a highlight range list — offsets plus the painted color
|
|
45
|
+
* (probed with a sentinel char) — so equal-length but differently-colored
|
|
46
|
+
* re-highlights (valid↔unknown at the same span) still trigger a redraw. */
|
|
47
|
+
function highlightSignature(hl?: readonly HighlightRange[]): string {
|
|
48
|
+
if (!hl || hl.length === 0) return "";
|
|
49
|
+
return hl.map(r => `${r.start}:${r.end}:${r.paint("\u0000")}`).join("|");
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export interface LaunchTuiOptions {
|
|
45
53
|
model: string;
|
|
46
54
|
/** Resolved provider name for the footer (anthropic / openai / gemini / ollama). */
|
|
@@ -68,6 +76,9 @@ export interface AgentEventsLike {
|
|
|
68
76
|
onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
|
|
69
77
|
onModelStream?(textSoFar: string): void;
|
|
70
78
|
onReasoningStream?(textSoFar: string): void;
|
|
79
|
+
/** Fired once when the model opens an extended-thinking block — drives a live "thinking"
|
|
80
|
+
* placeholder for signature-only reasoning models (opus-4-7/4-8) that stream no thought text. */
|
|
81
|
+
onReasoningStart?(): void;
|
|
71
82
|
/** Per-artifact native reasoning replay records (signature / thoughtSignature / reasoning
|
|
72
83
|
* item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
|
|
73
84
|
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
@@ -247,6 +258,11 @@ export class LaunchTui {
|
|
|
247
258
|
* streams, then persisted once into scrollback as a "Thinking" block on commit so the
|
|
248
259
|
* model's reasoning stays visible above the answer (gjc "think → answer" parity). */
|
|
249
260
|
private streamingThought = "";
|
|
261
|
+
/** True once the model opens an extended-thinking block this step. Signature-only
|
|
262
|
+
* reasoning models (opus-4-7/4-8) stream NO thinking text, so without this flag the
|
|
263
|
+
* live Thinking block never appears and the wait looks frozen. Drives a placeholder
|
|
264
|
+
* Thinking block until real thought/answer text streams. Reset each step / on commit. */
|
|
265
|
+
private thinkingActive = false;
|
|
250
266
|
/** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
|
|
251
267
|
private streamingActivity = "";
|
|
252
268
|
/** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
|
|
@@ -410,6 +426,7 @@ export class LaunchTui {
|
|
|
410
426
|
this.retryNotice = null; // a new step starts a fresh model call
|
|
411
427
|
this.streamingReasoning = ""; // fresh model response this step
|
|
412
428
|
this.streamingThought = "";
|
|
429
|
+
this.thinkingActive = false;
|
|
413
430
|
this.streamingActivity = "";
|
|
414
431
|
this.flushedReasoning = "";
|
|
415
432
|
this.flushedThought = "";
|
|
@@ -452,6 +469,14 @@ export class LaunchTui {
|
|
|
452
469
|
this.draw();
|
|
453
470
|
}
|
|
454
471
|
},
|
|
472
|
+
onReasoningStart: () => {
|
|
473
|
+
// The model opened an extended-thinking block. Signature-only reasoning models
|
|
474
|
+
// (opus-4-7/4-8) stream no thinking text, so flag the thinking phase so the live
|
|
475
|
+
// Thinking block renders a placeholder instead of leaving the wait blank.
|
|
476
|
+
if (this.finished || this.thinkingActive) return;
|
|
477
|
+
this.thinkingActive = true;
|
|
478
|
+
this.draw();
|
|
479
|
+
},
|
|
455
480
|
onAssistant: (_raw, invocation) => {
|
|
456
481
|
this.thinking = false; // model replied; now dispatching the tool
|
|
457
482
|
this.retryNotice = null; // the call got through — clear any backoff notice
|
|
@@ -484,6 +509,7 @@ export class LaunchTui {
|
|
|
484
509
|
}
|
|
485
510
|
this.streamingReasoning = "";
|
|
486
511
|
this.streamingThought = "";
|
|
512
|
+
this.thinkingActive = false;
|
|
487
513
|
this.streamingActivity = "";
|
|
488
514
|
if (invocation && invocation.tool !== "done") {
|
|
489
515
|
this.runningTool = true;
|
|
@@ -650,6 +676,18 @@ export class LaunchTui {
|
|
|
650
676
|
this.draw();
|
|
651
677
|
}
|
|
652
678
|
|
|
679
|
+
private livePromptHighlight?: readonly HighlightRange[];
|
|
680
|
+
/** Recolor every active/committed `/command`·`$skill` trigger token inside the
|
|
681
|
+
* mid-turn live input box (idle-prompt parity). Caller supplies code-point
|
|
682
|
+
* offsets into the draft text + a painter per token; undefined/empty clears. */
|
|
683
|
+
setLivePromptHighlight(hl?: readonly HighlightRange[]): void {
|
|
684
|
+
if (this.finished) return;
|
|
685
|
+
const next = hl && hl.length ? hl : undefined;
|
|
686
|
+
if (highlightSignature(this.livePromptHighlight) === highlightSignature(next)) return;
|
|
687
|
+
this.livePromptHighlight = next;
|
|
688
|
+
this.draw();
|
|
689
|
+
}
|
|
690
|
+
|
|
653
691
|
private livePromptHint: string[] = [];
|
|
654
692
|
/** Mid-turn command/skill preview lines shown above the live input box, so a
|
|
655
693
|
* /command or $skill typed WHILE a turn runs visibly reacts (idle-prompt parity). */
|
|
@@ -672,6 +710,7 @@ export class LaunchTui {
|
|
|
672
710
|
accentShadow: this.theme.color ? accentShadowPaint(this.theme) : undefined,
|
|
673
711
|
placeholder: "Type your next message...",
|
|
674
712
|
maxBodyRows: 2,
|
|
713
|
+
highlight: this.livePromptHighlight,
|
|
675
714
|
});
|
|
676
715
|
if (this.livePromptHint.length === 0) return box;
|
|
677
716
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
@@ -925,6 +964,7 @@ export class LaunchTui {
|
|
|
925
964
|
this.lastLedgerKind = null; // fresh turn: no leading spacer before the first ledger line
|
|
926
965
|
this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
|
|
927
966
|
this.livePromptHint = []; // fresh turn: no mid-turn command preview yet
|
|
967
|
+
this.livePromptHighlight = undefined; // fresh turn: no active trigger token
|
|
928
968
|
this.subagentLive = null; // fresh turn: no nested subagent in flight
|
|
929
969
|
this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
|
|
930
970
|
this.spinner.updateStep(0, this.footer.maxSteps);
|
|
@@ -1382,6 +1422,13 @@ export class LaunchTui {
|
|
|
1382
1422
|
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1383
1423
|
const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
|
|
1384
1424
|
tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
|
|
1425
|
+
} else if (isThinking && this.thinkingActive) {
|
|
1426
|
+
// Signature-only reasoning models (opus-4-7/4-8) open a thinking block but stream no
|
|
1427
|
+
// thought text — show a live placeholder so the wait reads as active thinking, not a
|
|
1428
|
+
// frozen screen. Replaced the instant any real thought/answer text streams (branch above).
|
|
1429
|
+
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1430
|
+
const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
|
|
1431
|
+
tail.push(...this.renderLiveBlock(liveLabel, "(thinking…)", cols, rows, 6, "Thinking"));
|
|
1385
1432
|
}
|
|
1386
1433
|
|
|
1387
1434
|
// Live tool output (gjc-style streaming bash stdout): while a tool runs, its
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { BOX_ASCII, BOX_UNICODE, boxBlock } from "./layout";
|
|
3
|
-
import { visibleWidth } from "./width";
|
|
3
|
+
import { visibleWidth, truncateToWidth } from "./width";
|
|
4
4
|
|
|
5
5
|
export interface InputBoxOptions {
|
|
6
6
|
cols?: number;
|
|
@@ -18,6 +18,20 @@ export interface InputBoxOptions {
|
|
|
18
18
|
/** Shadow painter for the bottom/right "shaded" edges; defaults to a dim accent.
|
|
19
19
|
* The lit-vs-shaded two-tone contrast gives the box visible depth. */
|
|
20
20
|
accentShadow?: (s: string) => string;
|
|
21
|
+
/** Paint contiguous CHARACTER ranges of the typed text (e.g. each active or
|
|
22
|
+
* committed `/command`/`$skill` trigger token) so the user sees every
|
|
23
|
+
* invocation recognized as it is typed — regardless of caret position or how
|
|
24
|
+
* many appear. Offsets index `Array.from(line)` code points ([start, end)).
|
|
25
|
+
* Accepts a single range or an array; ranges should not overlap. Ignored for
|
|
26
|
+
* the placeholder and when `color` is false. */
|
|
27
|
+
highlight?: HighlightRange | readonly HighlightRange[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A painted span of the input text: [start, end) code-point offsets + a painter. */
|
|
31
|
+
export interface HighlightRange {
|
|
32
|
+
start: number;
|
|
33
|
+
end: number;
|
|
34
|
+
paint: (s: string) => string;
|
|
21
35
|
}
|
|
22
36
|
|
|
23
37
|
export interface InputFrame {
|
|
@@ -38,6 +52,7 @@ function wrapWithCursor(
|
|
|
38
52
|
text: string,
|
|
39
53
|
cursor: number,
|
|
40
54
|
width: number,
|
|
55
|
+
highlights?: readonly HighlightRange[],
|
|
41
56
|
): { rows: string[]; row: number; col: number } {
|
|
42
57
|
const rows: string[] = [];
|
|
43
58
|
let cur = "";
|
|
@@ -67,7 +82,8 @@ function wrapWithCursor(
|
|
|
67
82
|
continue;
|
|
68
83
|
}
|
|
69
84
|
if (ch !== "") {
|
|
70
|
-
|
|
85
|
+
const hl = highlights?.find(r => i >= r.start && i < r.end);
|
|
86
|
+
cur += hl ? hl.paint(rendered) : rendered;
|
|
71
87
|
curW += w;
|
|
72
88
|
}
|
|
73
89
|
}
|
|
@@ -75,6 +91,16 @@ function wrapWithCursor(
|
|
|
75
91
|
return { rows, row, col };
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/** Normalize the `highlight` option (single range, array, or absent) into a
|
|
95
|
+
* non-empty range array, or undefined when there is nothing to paint. */
|
|
96
|
+
function normalizeHighlights(
|
|
97
|
+
h?: HighlightRange | readonly HighlightRange[],
|
|
98
|
+
): readonly HighlightRange[] | undefined {
|
|
99
|
+
if (!h) return undefined;
|
|
100
|
+
const arr = Array.isArray(h) ? h : [h as HighlightRange];
|
|
101
|
+
return arr.length ? arr : undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
78
104
|
/**
|
|
79
105
|
* Boxed input prompt (gjc-style): a `>` marker leads the first body row, the typed
|
|
80
106
|
* text (or a dim placeholder) follows, and the caret cell is reported so the caller
|
|
@@ -95,7 +121,8 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
|
|
|
95
121
|
rows = [placeholder];
|
|
96
122
|
placeholderRow = true;
|
|
97
123
|
} else {
|
|
98
|
-
const
|
|
124
|
+
const hl = useColor ? normalizeHighlights(opts.highlight) : undefined;
|
|
125
|
+
const wrapped = wrapWithCursor(line, opts.cursor ?? line.length, textWidth, hl);
|
|
99
126
|
rows = wrapped.rows;
|
|
100
127
|
crow = wrapped.row;
|
|
101
128
|
ccol = wrapped.col;
|
|
@@ -110,10 +137,10 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
|
|
|
110
137
|
hidden = Math.min(Math.max(0, crow - maxBodyRows + 1), totalRows - maxBodyRows);
|
|
111
138
|
if (crow < hidden) hidden = crow; // caret above the window → scroll up to it
|
|
112
139
|
rows = rows.slice(hidden, hidden + maxBodyRows);
|
|
113
|
-
if (hidden > 0) rows[0] = `…${rows[0] ?? ""}
|
|
140
|
+
if (hidden > 0) rows[0] = truncateToWidth(`…${rows[0] ?? ""}`, textWidth);
|
|
114
141
|
if (hidden + maxBodyRows < totalRows) {
|
|
115
142
|
const last = rows.length - 1;
|
|
116
|
-
rows[last] = `${rows[last] ?? ""}
|
|
143
|
+
rows[last] = truncateToWidth(`${rows[last] ?? ""}…`, textWidth);
|
|
117
144
|
}
|
|
118
145
|
}
|
|
119
146
|
let visRow = Math.max(0, Math.min(crow - hidden, rows.length - 1));
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rich, gjc-style session picker for `/resume`.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors Gajae-Code's session selector UX: a search/filter line at the top, a
|
|
5
|
+
* scrolling window of multi-line entries (title + dimmed first-message preview +
|
|
6
|
+
* a "relative time · size · N msgs" metadata line), a position indicator, and a
|
|
7
|
+
* footer with Del-to-delete / Enter-to-resume / Esc-to-cancel hints.
|
|
8
|
+
*
|
|
9
|
+
* Pure rendering — no I/O. The owning REPL drives navigation/deletion via the
|
|
10
|
+
* `SessionPicker` model and feeds the rendered lines to its picker loop.
|
|
11
|
+
*/
|
|
12
|
+
import chalk from "chalk";
|
|
13
|
+
import { truncateToWidth } from "./width";
|
|
14
|
+
import type { SessionSummary } from "../../agent/session";
|
|
15
|
+
|
|
16
|
+
/** Human-readable byte size (e.g. "0 B", "12.3 KB", "4.2 MB"). */
|
|
17
|
+
export function formatBytes(n: number | undefined): string {
|
|
18
|
+
const v = typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : 0;
|
|
19
|
+
if (v < 1024) return `${v} B`;
|
|
20
|
+
const units = ["KB", "MB", "GB", "TB"];
|
|
21
|
+
let size = v / 1024;
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (size >= 1024 && i < units.length - 1) {
|
|
24
|
+
size /= 1024;
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[i]}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Relative "X ago" timestamp, matching gjc's session-selector phrasing. */
|
|
31
|
+
export function formatRelativeTime(fromMs: number | undefined, nowMs: number = Date.now()): string {
|
|
32
|
+
if (typeof fromMs !== "number" || !Number.isFinite(fromMs) || fromMs <= 0) return "unknown";
|
|
33
|
+
const diff = Math.max(0, nowMs - fromMs);
|
|
34
|
+
const mins = Math.floor(diff / 60000);
|
|
35
|
+
const hours = Math.floor(diff / 3600000);
|
|
36
|
+
const days = Math.floor(diff / 86400000);
|
|
37
|
+
if (mins < 1) return "just now";
|
|
38
|
+
if (mins < 60) return `${mins} minute${mins !== 1 ? "s" : ""} ago`;
|
|
39
|
+
if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
|
|
40
|
+
if (days === 1) return "1 day ago";
|
|
41
|
+
if (days < 7) return `${days} days ago`;
|
|
42
|
+
return new Date(fromMs).toLocaleDateString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Navigable model for the resume picker: an ordered session list with a
|
|
47
|
+
* case-insensitive AND-of-terms filter across id/title/preview/cwd, a cursor
|
|
48
|
+
* into the *filtered* view, and in-place removal for delete.
|
|
49
|
+
*/
|
|
50
|
+
export class SessionPicker {
|
|
51
|
+
private readonly all: SessionSummary[];
|
|
52
|
+
private query = "";
|
|
53
|
+
private cursor = 0;
|
|
54
|
+
|
|
55
|
+
constructor(sessions: readonly SessionSummary[]) {
|
|
56
|
+
this.all = sessions.slice();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sessions matching the current filter (every whitespace term must match). */
|
|
60
|
+
visible(): SessionSummary[] {
|
|
61
|
+
const q = this.query.trim().toLowerCase();
|
|
62
|
+
if (!q) return this.all;
|
|
63
|
+
const terms = q.split(/\s+/);
|
|
64
|
+
return this.all.filter(s => {
|
|
65
|
+
const hay = [s.id, s.title ?? "", s.preview ?? "", s.cwd ?? ""].join(" ").toLowerCase();
|
|
66
|
+
return terms.every(t => hay.includes(t));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cursorIndex(): number {
|
|
71
|
+
const n = this.visible().length;
|
|
72
|
+
if (n === 0) return 0;
|
|
73
|
+
return Math.max(0, Math.min(this.cursor, n - 1));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
selected(): SessionSummary | undefined {
|
|
77
|
+
return this.visible()[this.cursorIndex()];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
isEmpty(): boolean {
|
|
81
|
+
return this.visible().length === 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
filter(): string {
|
|
85
|
+
return this.query;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
setFilter(query: string): void {
|
|
89
|
+
this.query = query;
|
|
90
|
+
this.cursor = 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
typeChar(ch: string): void {
|
|
94
|
+
this.setFilter(this.query + ch);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
backspace(): void {
|
|
98
|
+
this.setFilter(this.query.slice(0, -1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
up(): void {
|
|
102
|
+
const n = this.visible().length;
|
|
103
|
+
if (n === 0) return;
|
|
104
|
+
this.cursor = (this.cursorIndex() - 1 + n) % n;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
down(): void {
|
|
108
|
+
const n = this.visible().length;
|
|
109
|
+
if (n === 0) return;
|
|
110
|
+
this.cursor = (this.cursorIndex() + 1) % n;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Move by a window without wrapping (PageUp/PageDown). */
|
|
114
|
+
page(dir: 1 | -1, size = 3): void {
|
|
115
|
+
const n = this.visible().length;
|
|
116
|
+
if (n === 0) return;
|
|
117
|
+
this.cursor = Math.max(0, Math.min(n - 1, this.cursorIndex() + dir * Math.max(1, size)));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Remove the highlighted session from the model; returns it (or undefined). */
|
|
121
|
+
removeSelected(): SessionSummary | undefined {
|
|
122
|
+
const sel = this.selected();
|
|
123
|
+
if (!sel) return undefined;
|
|
124
|
+
const idx = this.all.findIndex(s => s.id === sel.id);
|
|
125
|
+
if (idx >= 0) this.all.splice(idx, 1);
|
|
126
|
+
const n = this.visible().length;
|
|
127
|
+
if (this.cursor >= n) this.cursor = Math.max(0, n - 1);
|
|
128
|
+
return sel;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface RenderSessionPickerOptions {
|
|
133
|
+
/** Title line(s) shown above the search line. */
|
|
134
|
+
title?: string;
|
|
135
|
+
/** Total width to fit each line to (default 80). */
|
|
136
|
+
cols?: number;
|
|
137
|
+
/** Total body rows available; the visible window is derived from this (default 24). */
|
|
138
|
+
rows?: number;
|
|
139
|
+
/** Use unicode glyphs (default true). */
|
|
140
|
+
unicode?: boolean;
|
|
141
|
+
/** Apply chalk color (default true). */
|
|
142
|
+
color?: boolean;
|
|
143
|
+
/** Clock override for relative-time formatting (tests). */
|
|
144
|
+
nowMs?: number;
|
|
145
|
+
/** When set, the matching session shows a "press Del again to delete" prompt. */
|
|
146
|
+
confirmDeleteId?: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Render a `SessionPicker` to lines (gjc-style multi-line entries). Pure. */
|
|
150
|
+
export function renderSessionPicker(picker: SessionPicker, opts: RenderSessionPickerOptions = {}): string[] {
|
|
151
|
+
const unicode = opts.unicode !== false;
|
|
152
|
+
const color = opts.color !== false;
|
|
153
|
+
const cols = Math.max(20, opts.cols ?? 80);
|
|
154
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
155
|
+
const tint = (s: string, fn: (x: string) => string): string => (color ? fn(s) : s);
|
|
156
|
+
const fit = (s: string): string => truncateToWidth(s, cols);
|
|
157
|
+
const pointer = unicode ? "\u276f" : ">"; // ❯
|
|
158
|
+
const dot = unicode ? "\u00b7" : "-"; // ·
|
|
159
|
+
const arrow = unicode ? "\u203a" : ">"; // ›
|
|
160
|
+
|
|
161
|
+
const out: string[] = [];
|
|
162
|
+
const titleLines = opts.title ? opts.title.split("\n") : [];
|
|
163
|
+
for (const t of titleLines) out.push(fit(t ? tint(t, chalk.bold) : ""));
|
|
164
|
+
|
|
165
|
+
// Search/filter line (gjc places an input box at the top).
|
|
166
|
+
const q = picker.filter();
|
|
167
|
+
const searchValue = q ? q : tint("type to filter", chalk.gray);
|
|
168
|
+
out.push(fit(`${tint("search", chalk.gray)} ${tint(arrow, chalk.cyan)} ${searchValue}`));
|
|
169
|
+
out.push("");
|
|
170
|
+
|
|
171
|
+
const items = picker.visible();
|
|
172
|
+
const footerKeys = unicode
|
|
173
|
+
? `\u2191/\u2193 move \u00b7 enter resume \u00b7 del delete \u00b7 esc cancel`
|
|
174
|
+
: `up/down move - enter resume - del delete - esc cancel`;
|
|
175
|
+
|
|
176
|
+
if (items.length === 0) {
|
|
177
|
+
out.push(fit(tint(" no sessions match", chalk.gray)));
|
|
178
|
+
out.push("");
|
|
179
|
+
out.push(fit(tint(` [${footerKeys}]`, chalk.gray)));
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Each entry occupies up to 3 content lines + 1 blank; derive the window from
|
|
184
|
+
// available rows, leaving room for title/search/footer chrome.
|
|
185
|
+
const linesPerItem = 4;
|
|
186
|
+
const chrome = titleLines.length + 2 /* search + blank */ + 2 /* position + footer */;
|
|
187
|
+
const avail = Math.max(linesPerItem, (opts.rows ?? 24) - chrome);
|
|
188
|
+
const maxVisible = Math.max(1, Math.min(items.length, Math.floor(avail / linesPerItem)));
|
|
189
|
+
|
|
190
|
+
const cur = picker.cursorIndex();
|
|
191
|
+
let start = Math.max(0, cur - Math.floor(maxVisible / 2));
|
|
192
|
+
start = Math.min(start, Math.max(0, items.length - maxVisible));
|
|
193
|
+
const end = Math.min(items.length, start + maxVisible);
|
|
194
|
+
|
|
195
|
+
for (let i = start; i < end; i++) {
|
|
196
|
+
const s = items[i]!;
|
|
197
|
+
const isCur = i === cur;
|
|
198
|
+
const isConfirm = !!opts.confirmDeleteId && s.id === opts.confirmDeleteId;
|
|
199
|
+
const cursorStr = isCur ? tint(`${pointer} `, chalk.cyan) : " ";
|
|
200
|
+
const maxw = Math.max(1, cols - 2); // cursor/indent prefix is 2 columns
|
|
201
|
+
const firstMsg = (s.preview || "(no preview)").replace(/\s+/g, " ").trim();
|
|
202
|
+
|
|
203
|
+
if (s.title) {
|
|
204
|
+
const titleTxt = truncateToWidth(s.title, maxw);
|
|
205
|
+
out.push(fit(cursorStr + (isCur ? tint(titleTxt, (x: string) => chalk.cyan.bold(x)) : titleTxt)));
|
|
206
|
+
out.push(fit(" " + tint(truncateToWidth(firstMsg, maxw), chalk.dim)));
|
|
207
|
+
} else {
|
|
208
|
+
const msg = truncateToWidth(firstMsg, maxw);
|
|
209
|
+
out.push(fit(cursorStr + (isCur ? tint(msg, (x: string) => chalk.cyan.bold(x)) : msg)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isConfirm) {
|
|
213
|
+
out.push(fit(tint(` press Del again to delete ${dot} any other key cancels`, chalk.yellow)));
|
|
214
|
+
} else {
|
|
215
|
+
const meta = ` ${formatRelativeTime(s.mtimeMs, nowMs)} ${dot} ${formatBytes(s.sizeBytes)} ${dot} ${s.messageCount} msg${s.messageCount !== 1 ? "s" : ""}`;
|
|
216
|
+
out.push(fit(tint(truncateToWidth(meta, cols), chalk.dim)));
|
|
217
|
+
}
|
|
218
|
+
out.push("");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (start > 0 || end < items.length) {
|
|
222
|
+
out.push(fit(tint(` (${cur + 1}/${items.length})`, chalk.gray)));
|
|
223
|
+
}
|
|
224
|
+
out.push(fit(tint(` [${footerKeys}]`, chalk.gray)));
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
@@ -216,6 +216,42 @@ export function activeTriggerToken(line: string): ActiveTrigger | undefined {
|
|
|
216
216
|
return { kind: token[0] as "/" | "$", token, start: (m.index ?? 0) + m[1]!.length };
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
+
/**
|
|
220
|
+
* The LEADING `/command` or `$skill` keyword once it has been committed with a
|
|
221
|
+
* trailing space — `"/model gpt-4"` → `/model`, `"$test the bug"` → `$test`.
|
|
222
|
+
* Unlike {@link activeTriggerToken} (which only matches the word the caret still
|
|
223
|
+
* sits on) this keeps the invoked keyword recognizable while arguments are typed,
|
|
224
|
+
* so the trigger highlight persists after the space instead of vanishing. Only
|
|
225
|
+
* the leading word counts — a command is invoked at the start of the line — and a
|
|
226
|
+
* still-being-typed keyword (no space yet) returns undefined so the active-token
|
|
227
|
+
* path owns it. Returns the same shape as {@link activeTriggerToken}.
|
|
228
|
+
*/
|
|
229
|
+
export function committedTriggerToken(line: string): ActiveTrigger | undefined {
|
|
230
|
+
const m = /^(\s*)([/$]\S+)\s/.exec(line);
|
|
231
|
+
if (!m) return undefined;
|
|
232
|
+
const token = m[2]!;
|
|
233
|
+
return { kind: token[0] as "/" | "$", token, start: Array.from(m[1]!).length };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* EVERY `/command` or `$skill` trigger token in the line (mention-style), in
|
|
238
|
+
* left-to-right order — `"/model x then $test y"` → [`/model`, `$test`]. Each
|
|
239
|
+
* is a whitespace-delimited word whose first char is `/`·`$` (paths like
|
|
240
|
+
* `src/cli` and vars like `FOO$BAR` stay excluded, just like the single-token
|
|
241
|
+
* helpers). `start` is the token's first-character index in `line`. Used to
|
|
242
|
+
* highlight all invocations at once, independent of caret position. Pure.
|
|
243
|
+
*/
|
|
244
|
+
export function allTriggerTokens(line: string): ActiveTrigger[] {
|
|
245
|
+
const out: ActiveTrigger[] = [];
|
|
246
|
+
const re = /(^|\s)([/$]\S*)/g;
|
|
247
|
+
let m: RegExpExecArray | null;
|
|
248
|
+
while ((m = re.exec(line))) {
|
|
249
|
+
const token = m[2]!;
|
|
250
|
+
out.push({ kind: token[0] as "/" | "$", token, start: m.index + m[1]!.length });
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
219
255
|
/**
|
|
220
256
|
* Compact live preview shown beneath the input box while a `/command` or
|
|
221
257
|
* `$skill` keyword is being typed — at any position in the line (mention-style,
|