jeo-code 0.6.29 → 0.6.31
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 +24 -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/AGENTS.md +1 -1
- package/src/agent/engine.ts +20 -33
- package/src/agent/loop-guards.ts +135 -0
- package/src/agent/session.ts +3 -0
- package/src/ai/providers/anthropic.ts +4 -0
- package/src/ai/types.ts +5 -0
- package/src/commands/launch.ts +83 -13
- package/src/tui/app.ts +39 -0
- package/src/tui/components/input-box.ts +12 -5
- package/src/tui/components/session-picker.ts +226 -0
- package/src/agent/tool-registry.ts +0 -54
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ 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.31] - 2026-06-19
|
|
10
|
+
_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._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **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.
|
|
18
|
+
|
|
19
|
+
### Verified
|
|
20
|
+
- **`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.
|
|
21
|
+
- **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).
|
|
22
|
+
|
|
23
|
+
## [0.6.30] - 2026-06-19
|
|
24
|
+
_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._
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- **Loop intermediate-judgment guards extracted into a classified module (`src/agent/loop-guards.ts`).** The mid-run "continue / self-correct / stop" decisions that were inlined across `engine.ts`'s `while` loop as scattered booleans and message strings are now a named `GuardState` discriminated-union taxonomy — jeo's descendant of gjc's `ultragoal-guard` `UltragoalGuardState` pattern. A single frozen `GUARD_LIMITS` object is the source of truth for every threshold (`MAX_REPEAT`, `MAX_FAILURES`, `MAX_REFUSAL_RETRIES`, `MAX_INVALID_CALLS`, `MAX_PARSE_BOUNCES`, `CYCLE_WINDOW`), and pure classifiers (`isVerificationSignal`, `repeatHint`, `nearestToolName`, `classifyDoneGate`) are now independently testable. `engine.ts` still owns all control flow (history mutation, `step++`, `continue`, `return finish(...)`) — only the JUDGMENT moved, so behavior is unchanged (net −19 lines in `engine.ts`). Removed the now-unused `src/agent/tool-registry.ts`.
|
|
28
|
+
|
|
29
|
+
### Verified
|
|
30
|
+
- **`jeo --tmux` has no bun memory leak and does not slow down.** An in-process probe streaming 5,000,000 SGR mouse-report escapes through `queuePromptInputChunk` (10 × 500k, `Bun.gc(true)` between batches) holds RSS flat (133.9 → 135.2 MB, slope ≈0.13 MB/round) with zero prompt-queue accumulation; a real `jeo --tmux` session flooded with 60k live mouse reports via `tmux send-keys` plateaus in RSS (129,456 → 129,472 KB). `jeo --tmux -p` end-to-end creates the profiled session, runs the turn, and tears down cleanly.
|
|
31
|
+
- **Full suite green:** `bun run typecheck` clean and `bun test` 1687 pass / 0 fail across 210 files (includes the new `test/loop-guards.test.ts`, 9 tests, and the signature-only Anthropic replay test).
|
|
32
|
+
|
|
9
33
|
## [0.6.29] - 2026-06-19
|
|
10
34
|
_Signature-only thinking-block replay (Anthropic opus-4-7/4-8), plus a tmux mouse-flood memory guard confirming `jeo --tmux` does not leak._
|
|
11
35
|
|
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.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.
|
|
204
|
+
- **[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.
|
|
203
205
|
- **[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.
|
|
204
206
|
- **[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).
|
|
205
207
|
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
206
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
207
|
-
- **[0.6.25]** (2026-06-19) — Reasoning works at every thinking level (gajae parity), and the forge emblem is redrawn as the neon-lens coding wizard.
|
|
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.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.
|
|
204
|
+
- **[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.
|
|
203
205
|
- **[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.
|
|
204
206
|
- **[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).
|
|
205
207
|
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
206
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
207
|
-
- **[0.6.25]** (2026-06-19) — Reasoning works at every thinking level (gajae parity), and the forge emblem is redrawn as the neon-lens coding wizard.
|
|
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.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.
|
|
204
|
+
- **[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.
|
|
203
205
|
- **[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.
|
|
204
206
|
- **[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).
|
|
205
207
|
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
206
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
207
|
-
- **[0.6.25]** (2026-06-19) — Reasoning works at every thinking level (gajae parity), and the forge emblem is redrawn as the neon-lens coding wizard.
|
|
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.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.
|
|
204
|
+
- **[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.
|
|
203
205
|
- **[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.
|
|
204
206
|
- **[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).
|
|
205
207
|
- **[0.6.27]** (2026-06-19) — Ponytail pass on the reasoning-tier mapper, plus a real-tmux verification of `jeo --tmux`.
|
|
206
|
-
- **[0.6.26]** (2026-06-19) — The forge emblem is redrawn again as the mascot crayfish, foregrounding its signature pincer claws (집게).
|
|
207
|
-
- **[0.6.25]** (2026-06-19) — Reasoning works at every thinking level (gajae parity), and the forge emblem is redrawn as the neon-lens coding wizard.
|
|
208
208
|
|
|
209
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
210
210
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/AGENTS.md
CHANGED
|
@@ -17,6 +17,7 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
17
17
|
| `hooks.ts` | Brief description of purpose |
|
|
18
18
|
| `json.ts` | Brief description of purpose |
|
|
19
19
|
| `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
|
|
20
|
+
| `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`isVerificationSignal`, `repeatHint`, `classifyDoneGate`) consumed by `engine.ts` |
|
|
20
21
|
| `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
|
|
21
22
|
| `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
|
|
22
23
|
| `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
|
|
@@ -35,7 +36,6 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
35
36
|
| `todo-tool.ts` | Brief description of purpose |
|
|
36
37
|
| `tokenizer.ts` | Brief description of purpose |
|
|
37
38
|
| `tool-output.ts` | Brief description of purpose |
|
|
38
|
-
| `tool-registry.ts` | Brief description of purpose |
|
|
39
39
|
| `tools.ts` | Built-in tool definitions (bash, read, write, edit, etc.) |
|
|
40
40
|
| `web-search.ts` | Brief description of purpose |
|
|
41
41
|
|
package/src/agent/engine.ts
CHANGED
|
@@ -22,6 +22,7 @@ export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFA
|
|
|
22
22
|
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
|
|
23
23
|
import { historyTokens, trimToolResultsInPlace } from "./compaction";
|
|
24
24
|
import { jeoEnv } from "../util/env";
|
|
25
|
+
import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
async function invokeCallLlm(history: Message[], options: {
|
|
@@ -34,6 +35,7 @@ async function invokeCallLlm(history: Message[], options: {
|
|
|
34
35
|
onRetry?: (attempt: number, err: unknown, delayMs: number) => void;
|
|
35
36
|
onToken?: (delta: string) => void;
|
|
36
37
|
onReasoning?: (delta: string) => void;
|
|
38
|
+
onReasoningStart?: () => void;
|
|
37
39
|
onReasoningArtifact?: (artifact: import("../ai/types").ReasoningArtifact) => void;
|
|
38
40
|
tools?: import("../ai/types").NativeToolSchema[];
|
|
39
41
|
}): Promise<string> {
|
|
@@ -195,6 +197,10 @@ export interface AgentLoopEvents {
|
|
|
195
197
|
/** Accumulated native reasoning/thinking text so far — drives a transient dimmed
|
|
196
198
|
* "thinking" view. Only requested when a consumer (TUI) attaches. */
|
|
197
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;
|
|
198
204
|
/** Each provider-native reasoning ARTIFACT as it is captured (signature / thoughtSignature /
|
|
199
205
|
* reasoning item). Lets the final-reply path (launch.ts) persist artifacts for replay. */
|
|
200
206
|
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
@@ -378,29 +384,15 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
378
384
|
} catch { /* best-effort; fall through to the plain stop message */ }
|
|
379
385
|
return finish({ done: false, steps: step, doneReason: `Stopped: ${stopReason}` });
|
|
380
386
|
};
|
|
381
|
-
// Result-aware repeat nudge (A): tell the model WHY repeating won't help and what to
|
|
382
|
-
// try instead, tailored to the repeated tool and its last actual result.
|
|
383
|
-
const repeatHint = (tool: string, prev?: { success: boolean; output: string }): string => {
|
|
384
|
-
const out = prev?.output ?? "";
|
|
385
|
-
const empty = !prev || !prev.success || out.trim() === "" || /no match|0 match|no result|not found|no file/i.test(out);
|
|
386
|
-
if (tool === "search" || tool === "find" || tool === "ls") {
|
|
387
|
-
return empty
|
|
388
|
-
? `That '${tool}' returned nothing useful and will again — BROADEN it (a looser pattern, a parent directory, or a different tool such as ${tool === "search" ? "find" : "search"}), or call done if this lookup isn't needed.`
|
|
389
|
-
: `That '${tool}' already returned results — open one of the hits with read, or move on; re-running it changes nothing.`;
|
|
390
|
-
}
|
|
391
|
-
if (tool === "read") return `You already read that and its content is unchanged — use what you read, or read a DIFFERENT file.`;
|
|
392
|
-
if (tool === "bash") return `That command already ran with the same output — change the command, or call done.`;
|
|
393
|
-
return `That call's result is unchanged — take a different action, or call done.`;
|
|
394
|
-
};
|
|
395
387
|
// No-progress guard: weak/local models often repeat the same tool call without
|
|
396
388
|
// ever emitting `done`. Two escalating corrections (B), then a consolidated stop.
|
|
397
|
-
const MAX_REPEAT =
|
|
389
|
+
const MAX_REPEAT = GUARD_LIMITS.MAX_REPEAT;
|
|
398
390
|
// Last executed step's per-call results — fed to repeatHint so a corrective bounce
|
|
399
391
|
// can cite the repeated call's ACTUAL last outcome (A).
|
|
400
392
|
let lastResults: { success: boolean; output: string; executed: boolean }[] = [];
|
|
401
393
|
// Consecutive-failure guard: a model that keeps emitting *different* but failing
|
|
402
394
|
// calls (bad edits, failing commands) would otherwise burn the whole step budget.
|
|
403
|
-
const MAX_FAILURES =
|
|
395
|
+
const MAX_FAILURES = GUARD_LIMITS.MAX_FAILURES;
|
|
404
396
|
let consecutiveFailures = 0;
|
|
405
397
|
// done-verification guard (plan/gjc-inheritance.md B4, gjc ultragoal-guard 경량 계승):
|
|
406
398
|
// a turn that MUTATED files but shows no verification signal gets ONE pushback on
|
|
@@ -424,16 +416,15 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
424
416
|
// as-is, then once more with an explicit re-grounding note; only a third
|
|
425
417
|
// refusal in the turn surfaces the (friendly) error. Bounded per turn so a
|
|
426
418
|
// genuinely refused request can never burn billed calls in a loop.
|
|
427
|
-
const MAX_REFUSAL_RETRIES =
|
|
419
|
+
const MAX_REFUSAL_RETRIES = GUARD_LIMITS.MAX_REFUSAL_RETRIES;
|
|
428
420
|
let refusalRetries = 0;
|
|
429
|
-
const VERIFY_SIGNAL_RE = /\b(test|tests|tsc|typecheck|lint|build|check|spec|pytest|vitest|jest)\b/i;
|
|
430
421
|
let lastSig = "";
|
|
431
422
|
let repeatCount = 0;
|
|
432
423
|
// Cycle guard (the A↔B ping-pong the exact-repeat guard cannot see): the recent
|
|
433
424
|
// executed step signatures, as fixed-size digests. When a full window cycles
|
|
434
425
|
// through ≤2 distinct calls, bounce ONCE with an explicit correction; a spin that
|
|
435
426
|
// persists through the correction stops the turn.
|
|
436
|
-
const CYCLE_WINDOW =
|
|
427
|
+
const CYCLE_WINDOW = GUARD_LIMITS.CYCLE_WINDOW;
|
|
437
428
|
const recentStepSigs: string[] = [];
|
|
438
429
|
let cycleBounceUsed = false;
|
|
439
430
|
// Invalid-tool-call guard: a model that returns JSON without a usable `tool`
|
|
@@ -441,10 +432,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
441
432
|
let invalidToolCalls = 0;
|
|
442
433
|
// A JSON reply with no usable `tool` field can't drive the loop — stop sooner than the
|
|
443
434
|
// repeat-spin guard (no escalating correction helps a model that isn't producing a call).
|
|
444
|
-
const MAX_INVALID_CALLS =
|
|
435
|
+
const MAX_INVALID_CALLS = GUARD_LIMITS.MAX_INVALID_CALLS;
|
|
445
436
|
// Prose-bounce guard: after this many invalid-JSON corrections, salvage the
|
|
446
437
|
// model's text as the final answer instead of burning the whole step budget.
|
|
447
|
-
const MAX_PARSE_BOUNCES =
|
|
438
|
+
const MAX_PARSE_BOUNCES = GUARD_LIMITS.MAX_PARSE_BOUNCES;
|
|
448
439
|
let parseFailures = 0;
|
|
449
440
|
while (true) {
|
|
450
441
|
if (turnBudgetMs > 0 && Date.now() - turnStartedAt > turnBudgetMs) {
|
|
@@ -540,6 +531,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
540
531
|
onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
|
|
541
532
|
onToken,
|
|
542
533
|
onReasoning,
|
|
534
|
+
onReasoningStart: ev.onReasoningStart,
|
|
543
535
|
onReasoningArtifact,
|
|
544
536
|
// Make provider auto-retry visible: previously a rate-limited call sat in a
|
|
545
537
|
// silent backoff wait, then surfaced "auto-retry was exhausted" with no trace
|
|
@@ -703,19 +695,14 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
703
695
|
ev.onAssistant?.(responseText, toolCalls[0]);
|
|
704
696
|
|
|
705
697
|
if (toolCalls.length === 1 && toolCalls[0].tool === "done") {
|
|
706
|
-
|
|
698
|
+
// done-verification gate — jeo's descendant of gjc's ultragoal-guard completion
|
|
699
|
+
// state machine (plan/gjc-inheritance.md B4). The classifier owns the JUDGMENT
|
|
700
|
+
// (which named state, which message); the loop owns the once-pushback latch.
|
|
701
|
+
const doneGate = classifyDoneGate({ sawMutation, sawVerification, pendingHookFailure });
|
|
702
|
+
if (doneGate.block && !donePushbackUsed) {
|
|
707
703
|
donePushbackUsed = true; // second done always passes — escape hatch
|
|
708
704
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
709
|
-
history.push({
|
|
710
|
-
role: "user",
|
|
711
|
-
content: pendingHookFailure !== null
|
|
712
|
-
? `Your latest mutation left the post-turn hook "${pendingHookFailure}" FAILING (non-zero exit) — its diagnostics were shown in the tool result above. ` +
|
|
713
|
-
"Fix the reported problems (the hook re-runs on your next mutation), then call done. " +
|
|
714
|
-
"If the hook failure is a false positive, call done again and say why in the reason."
|
|
715
|
-
: "You modified files this turn but ran NO verification (no test/build/typecheck command succeeded). " +
|
|
716
|
-
"Run the narrowest command that proves your change works, then call done. " +
|
|
717
|
-
"If verification is genuinely not applicable (docs/config-only change), call done again and say why in the reason.",
|
|
718
|
-
});
|
|
705
|
+
history.push({ role: "user", content: doneGate.message });
|
|
719
706
|
step++;
|
|
720
707
|
continue;
|
|
721
708
|
}
|
|
@@ -1039,7 +1026,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1039
1026
|
if (t === "write" || t === "edit") sawMutation = true;
|
|
1040
1027
|
else if (t === "bash") {
|
|
1041
1028
|
const cmd = String(toolCalls[i].arguments?.command ?? "");
|
|
1042
|
-
if (
|
|
1029
|
+
if (isVerificationSignal(cmd, results[i].output)) sawVerification = true;
|
|
1043
1030
|
}
|
|
1044
1031
|
}
|
|
1045
1032
|
// F6 (round 4 architect, Low): judge the step by its NON-TRIVIAL calls — a
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intermediate-judgment guards for the agent loop — the mid-run "should this turn
|
|
3
|
+
* continue, correct itself, or stop" decisions that run between model calls.
|
|
4
|
+
*
|
|
5
|
+
* gjc keeps this concern in its own layer: `gjc-runtime/ultragoal-guard.ts` computes a
|
|
6
|
+
* named `UltragoalGuardState` discriminated union PURELY, and the runtime merely acts on
|
|
7
|
+
* the verdict. jeo previously inlined the same logic inside `engine.ts`'s `while` loop as
|
|
8
|
+
* scattered booleans and message strings. This module gives jeo the same classification:
|
|
9
|
+
* a named `GuardState` taxonomy plus pure, independently-testable classifier functions.
|
|
10
|
+
* `engine.ts` still owns the control flow (history mutation, `step++`, `continue`,
|
|
11
|
+
* `return finish(...)`) — only the JUDGMENT moves here, so behavior is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Named taxonomy of the loop's intermediate judgments — jeo's descendant of gjc's
|
|
16
|
+
* `UltragoalGuardState`. Each member names one decision the loop can reach mid-turn.
|
|
17
|
+
*/
|
|
18
|
+
export type GuardState =
|
|
19
|
+
| "ok" // proceed: emit / execute the tool call as-is
|
|
20
|
+
| "repeat_correct" // exact-repeat detected → ONE corrective bounce (skip execution)
|
|
21
|
+
| "repeat_stop" // exact-repeat survived the correction → consolidate-stop
|
|
22
|
+
| "cycle_correct" // A↔B alternation detected → ONE corrective bounce
|
|
23
|
+
| "cycle_stop" // cycle survived the correction → consolidate-stop
|
|
24
|
+
| "consecutive_failure_stop" // MAX_FAILURES different-but-failing steps → stop
|
|
25
|
+
| "invalid_tool_stop" // MAX_INVALID_CALLS replies with no usable tool field → stop
|
|
26
|
+
| "parse_salvage" // repeated non-JSON prose → salvage the text as the final answer
|
|
27
|
+
| "context_overflow_retry" // provider reported context overflow → ONE trim + retry
|
|
28
|
+
| "refusal_retry" // transient safety refusal → bounded resend ladder
|
|
29
|
+
| "done_unverified" // mutated files, no verification signal → pushback on done
|
|
30
|
+
| "done_hook_failing" // post-turn hook still failing → pushback on done
|
|
31
|
+
| "done_ok"; // done accepted — the turn is finished
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Bounded thresholds for every loop guard — the single, named source of truth.
|
|
35
|
+
* Kept in one frozen object so the limits are discoverable and testable instead of
|
|
36
|
+
* sprinkled as bare literals through the loop body.
|
|
37
|
+
*/
|
|
38
|
+
export const GUARD_LIMITS = Object.freeze({
|
|
39
|
+
/** Identical step repeats tolerated before a consolidated stop (with corrections en route). */
|
|
40
|
+
MAX_REPEAT: 4,
|
|
41
|
+
/** Consecutive different-but-failing steps before the turn stops. */
|
|
42
|
+
MAX_FAILURES: 5,
|
|
43
|
+
/** Safety-refusal resends per turn before surfacing the friendly error. */
|
|
44
|
+
MAX_REFUSAL_RETRIES: 3,
|
|
45
|
+
/** Replies with no usable `tool`/`tools` field before the turn stops. */
|
|
46
|
+
MAX_INVALID_CALLS: 3,
|
|
47
|
+
/** Consecutive non-JSON parse failures before the prose is salvaged as the answer. */
|
|
48
|
+
MAX_PARSE_BOUNCES: 2,
|
|
49
|
+
/** Recent-signature window scanned for an A↔B (≤2 distinct calls) cycle. */
|
|
50
|
+
CYCLE_WINDOW: 6,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Commands (or their output) that count as a verification signal: a test, build,
|
|
55
|
+
* typecheck, or lint invocation. The done-verification guard treats a turn that mutated
|
|
56
|
+
* files without any such signal as "unverified".
|
|
57
|
+
*/
|
|
58
|
+
export const VERIFY_SIGNAL_RE = /\b(test|tests|tsc|typecheck|lint|build|check|spec|pytest|vitest|jest)\b/i;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when a bash command (or the head of its output) proves the work was verified.
|
|
62
|
+
* Output is examined only up to the first 2000 chars — enough to catch a tool runner's
|
|
63
|
+
* banner without rescanning a megabyte of logs.
|
|
64
|
+
*/
|
|
65
|
+
export function isVerificationSignal(cmd: string, output = ""): boolean {
|
|
66
|
+
return VERIFY_SIGNAL_RE.test(cmd) || VERIFY_SIGNAL_RE.test(output.slice(0, 2000));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Result-aware repeat nudge: tells the model WHY repeating the call won't help and what
|
|
71
|
+
* to try instead, tailored to the repeated tool and its last actual result.
|
|
72
|
+
*/
|
|
73
|
+
export function repeatHint(tool: string, prev?: { success: boolean; output: string }): string {
|
|
74
|
+
const out = prev?.output ?? "";
|
|
75
|
+
const empty = !prev || !prev.success || out.trim() === "" || /no match|0 match|no result|not found|no file/i.test(out);
|
|
76
|
+
if (tool === "search" || tool === "find" || tool === "ls") {
|
|
77
|
+
return empty
|
|
78
|
+
? `That '${tool}' returned nothing useful and will again — BROADEN it (a looser pattern, a parent directory, or a different tool such as ${tool === "search" ? "find" : "search"}), or call done if this lookup isn't needed.`
|
|
79
|
+
: `That '${tool}' already returned results — open one of the hits with read, or move on; re-running it changes nothing.`;
|
|
80
|
+
}
|
|
81
|
+
if (tool === "read") return `You already read that and its content is unchanged — use what you read, or read a DIFFERENT file.`;
|
|
82
|
+
if (tool === "bash") return `That command already ran with the same output — change the command, or call done.`;
|
|
83
|
+
return `That call's result is unchanged — take a different action, or call done.`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Inputs for the done-verification gate (jeo's descendant of gjc's ultragoal-guard). */
|
|
87
|
+
export interface DoneGateInput {
|
|
88
|
+
/** A write/edit succeeded this turn. */
|
|
89
|
+
sawMutation: boolean;
|
|
90
|
+
/** A test/build/typecheck/lint command succeeded this turn. */
|
|
91
|
+
sawVerification: boolean;
|
|
92
|
+
/** The run-command of the most recent still-failing post-turn hook, or null. */
|
|
93
|
+
pendingHookFailure: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
|
|
97
|
+
export interface DoneGateVerdict {
|
|
98
|
+
state: Extract<GuardState, "done_ok" | "done_unverified" | "done_hook_failing">;
|
|
99
|
+
/** When true, `done` should be bounced ONCE with `message` (the caller owns the once-gate). */
|
|
100
|
+
block: boolean;
|
|
101
|
+
/** Corrective message to push back on `done`; empty when `state === "done_ok"`. */
|
|
102
|
+
message: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Classify whether a `done` should be accepted or bounced — the direct descendant of
|
|
107
|
+
* gjc's `ultragoal-guard` completion gate (plan/gjc-inheritance.md B4).
|
|
108
|
+
*
|
|
109
|
+
* A turn that MUTATED files but has either NO verification signal or a still-failing
|
|
110
|
+
* post-turn hook is blocked ONCE. The caller owns the single-pushback latch; a second
|
|
111
|
+
* `done` always passes (the escape hatch for genuinely-unverifiable docs/config changes).
|
|
112
|
+
*/
|
|
113
|
+
export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
114
|
+
const hookFailing = input.pendingHookFailure !== null;
|
|
115
|
+
const block = input.sawMutation && (!input.sawVerification || hookFailing);
|
|
116
|
+
if (!block) return { state: "done_ok", block: false, message: "" };
|
|
117
|
+
if (hookFailing) {
|
|
118
|
+
return {
|
|
119
|
+
state: "done_hook_failing",
|
|
120
|
+
block: true,
|
|
121
|
+
message:
|
|
122
|
+
`Your latest mutation left the post-turn hook "${input.pendingHookFailure}" FAILING (non-zero exit) — its diagnostics were shown in the tool result above. ` +
|
|
123
|
+
"Fix the reported problems (the hook re-runs on your next mutation), then call done. " +
|
|
124
|
+
"If the hook failure is a false positive, call done again and say why in the reason.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
state: "done_unverified",
|
|
129
|
+
block: true,
|
|
130
|
+
message:
|
|
131
|
+
"You modified files this turn but ran NO verification (no test/build/typecheck command succeeded). " +
|
|
132
|
+
"Run the narrowest command that proves your change works, then call done. " +
|
|
133
|
+
"If verification is genuinely not applicable (docs/config-only change), call done again and say why in the reason.",
|
|
134
|
+
};
|
|
135
|
+
}
|
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 {
|
|
@@ -353,8 +353,12 @@ export const anthropicAdapter: ProviderAdapter = {
|
|
|
353
353
|
toolBlocks.set(evt.index, { name: evt.content_block.name ?? "", args: "" });
|
|
354
354
|
} else if (evt.type === "content_block_start" && evt.content_block?.type === "thinking" && typeof evt.index === "number") {
|
|
355
355
|
thinkBlocks.set(evt.index, { text: "" });
|
|
356
|
+
// Signal the thinking phase started so the UI shows a live "thinking" indicator
|
|
357
|
+
// even for signature-only models (opus-4-7/4-8) that stream NO thinking_delta text.
|
|
358
|
+
options.onReasoningStart?.();
|
|
356
359
|
} else if (evt.type === "content_block_start" && evt.content_block?.type === "redacted_thinking" && evt.content_block.data) {
|
|
357
360
|
// Redacted thinking carries opaque `data` directly (no deltas) — emit immediately.
|
|
361
|
+
options.onReasoningStart?.();
|
|
358
362
|
options.onReasoningArtifact?.({ provider: "anthropic", model: options.model, redacted: evt.content_block.data });
|
|
359
363
|
} else if (evt.type === "content_block_delta" && evt.delta?.type === "input_json_delta" && typeof evt.index === "number") {
|
|
360
364
|
const b = toolBlocks.get(evt.index);
|
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
|
@@ -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,
|
|
@@ -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,26 @@ 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
|
+
): { start: number; end: number; paint: (s: string) => string } | undefined => {
|
|
1807
|
+
if (!uiTheme.color) return undefined;
|
|
1808
|
+
const trigger = activeTriggerToken(rendered);
|
|
1809
|
+
if (!trigger) return undefined;
|
|
1810
|
+
const start = Array.from(rendered.slice(0, trigger.start)).length;
|
|
1811
|
+
const end = start + Array.from(trigger.token).length;
|
|
1812
|
+
const valid = slashPreviewMatches(rendered, skillSlashDetails, resolvedSkills).length > 0;
|
|
1813
|
+
const hex = valid ? TRIGGER_HL_VALID : TRIGGER_HL_UNKNOWN;
|
|
1814
|
+
return { start, end, paint: (s: string) => chalk.hex(hex)(s) };
|
|
1815
|
+
};
|
|
1793
1816
|
const refreshUiTheme = (): void => {
|
|
1794
1817
|
uiTheme = resolveTheme(process.env);
|
|
1795
1818
|
uiAccent = accentPaint(uiTheme);
|
|
@@ -1825,7 +1848,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1825
1848
|
const rli = rl as unknown as { line?: string; cursor?: number };
|
|
1826
1849
|
const caret = rli.line === line && typeof rli.cursor === "number" ? rli.cursor : line.length;
|
|
1827
1850
|
const { accent: boxAccent, shadow: boxShadow } = boxAccents(line);
|
|
1828
|
-
const
|
|
1851
|
+
const rendered = expandSentinel(line);
|
|
1852
|
+
const frame = renderInputFrame(rendered, {
|
|
1829
1853
|
// Full terminal width (cols is already columns - 1, leaving the last column free
|
|
1830
1854
|
// so a full-width row never wraps). Matches the live-turn box, user/forge cards,
|
|
1831
1855
|
// and the welcome banner — all share this cols-1 width so nothing jumps on the
|
|
@@ -1841,6 +1865,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1841
1865
|
: undefined,
|
|
1842
1866
|
maxBodyRows: Math.max(1, footerRows - 7),
|
|
1843
1867
|
cursor: caret,
|
|
1868
|
+
highlight: triggerHighlight(rendered),
|
|
1844
1869
|
});
|
|
1845
1870
|
const input = frame.lines.map(l => truncateAnsi(l, cols));
|
|
1846
1871
|
// jeo-ref layout: a blank spacer row between the status bar (row 0) and the
|
|
@@ -2930,26 +2955,71 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2930
2955
|
if (arg) { await applyResume(arg); continue; }
|
|
2931
2956
|
// No id → only sessions with a real conversation are resumable (every launch
|
|
2932
2957
|
// creates an empty session; those are noise).
|
|
2933
|
-
|
|
2934
|
-
if (
|
|
2958
|
+
let pool = (await listSessions(cwd)).filter(s => s.messageCount > 0);
|
|
2959
|
+
if (pool.length === 0) {
|
|
2935
2960
|
console.log("(no saved sessions with history)");
|
|
2936
2961
|
continue;
|
|
2937
2962
|
}
|
|
2938
|
-
// Interactive
|
|
2963
|
+
// Interactive gjc-style picker on a TTY: type to filter, ↑↓/PgUp/PgDn to
|
|
2964
|
+
// move, Enter resumes, Del deletes (press Del twice to confirm), Esc cancels.
|
|
2939
2965
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2966
|
+
// Loop so a delete refreshes the list and re-opens the picker in place.
|
|
2967
|
+
for (;;) {
|
|
2968
|
+
const picker = new SessionPicker(pool);
|
|
2969
|
+
let action: { kind: "resume" | "delete"; id: string } | undefined;
|
|
2970
|
+
let confirmDeleteId: string | undefined;
|
|
2971
|
+
await runSelectPicker(
|
|
2972
|
+
(cols, rows) => renderSessionPicker(picker, {
|
|
2973
|
+
title: "Resume a session",
|
|
2974
|
+
cols,
|
|
2975
|
+
rows: Math.max(8, rows),
|
|
2976
|
+
unicode: true,
|
|
2977
|
+
color: true,
|
|
2978
|
+
confirmDeleteId,
|
|
2979
|
+
}),
|
|
2980
|
+
(ch, key) => {
|
|
2981
|
+
if (key?.name === "up") { confirmDeleteId = undefined; picker.up(); return false; }
|
|
2982
|
+
if (key?.name === "down") { confirmDeleteId = undefined; picker.down(); return false; }
|
|
2983
|
+
if (key?.name === "pageup") { confirmDeleteId = undefined; picker.page(-1); return false; }
|
|
2984
|
+
if (key?.name === "pagedown") { confirmDeleteId = undefined; picker.page(1); return false; }
|
|
2985
|
+
if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
|
|
2986
|
+
if (key?.name === "delete") {
|
|
2987
|
+
const sel = picker.selected();
|
|
2988
|
+
if (!sel) return false;
|
|
2989
|
+
if (confirmDeleteId === sel.id) { action = { kind: "delete", id: sel.id }; return true; }
|
|
2990
|
+
confirmDeleteId = sel.id;
|
|
2991
|
+
return false;
|
|
2992
|
+
}
|
|
2993
|
+
if (key?.name === "return" || key?.name === "enter") {
|
|
2994
|
+
const sel = picker.selected();
|
|
2995
|
+
if (sel) { action = { kind: "resume", id: sel.id }; return true; }
|
|
2996
|
+
return false;
|
|
2997
|
+
}
|
|
2998
|
+
confirmDeleteId = undefined;
|
|
2999
|
+
if (key?.name === "backspace") { picker.backspace(); return false; }
|
|
3000
|
+
if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
|
|
3001
|
+
return false;
|
|
3002
|
+
},
|
|
3003
|
+
);
|
|
3004
|
+
if (!action) { console.log("(resume cancelled)"); break; }
|
|
3005
|
+
if (action.kind === "resume") { await applyResume(action.id); break; }
|
|
3006
|
+
// Delete: drop the file, refresh the pool, and re-open the picker.
|
|
3007
|
+
const delId = action.id;
|
|
3008
|
+
try {
|
|
3009
|
+
const removed = await deleteSession(delId, cwd);
|
|
3010
|
+
console.log(removed ? `(deleted session ${delId})` : `(session ${delId} already gone)`);
|
|
3011
|
+
} catch (err) {
|
|
3012
|
+
console.log(`! delete failed: ${(err as Error).message}`);
|
|
3013
|
+
}
|
|
3014
|
+
if (delId === sessionId) await startFreshSession("dropped current session");
|
|
3015
|
+
pool = pool.filter(s => s.id !== delId);
|
|
3016
|
+
if (pool.length === 0) { console.log("(no saved sessions with history)"); break; }
|
|
3017
|
+
}
|
|
2948
3018
|
continue;
|
|
2949
3019
|
}
|
|
2950
3020
|
// Non-TTY fallback: static list (resume with /session resume <id>).
|
|
2951
3021
|
console.log("Saved sessions — resume with /session resume <id>:");
|
|
2952
|
-
for (const s of
|
|
3022
|
+
for (const s of pool.slice(0, 15)) {
|
|
2953
3023
|
const marker = s.id === sessionId ? "*" : " ";
|
|
2954
3024
|
console.log(` ${marker}${s.id} (${s.messageCount} msgs) ${s.title ? `[${s.title}] ` : ""}${s.preview}`);
|
|
2955
3025
|
}
|
package/src/tui/app.ts
CHANGED
|
@@ -68,6 +68,9 @@ export interface AgentEventsLike {
|
|
|
68
68
|
onUsage?(usage: { inputTokens: number; outputTokens: number }): void;
|
|
69
69
|
onModelStream?(textSoFar: string): void;
|
|
70
70
|
onReasoningStream?(textSoFar: string): void;
|
|
71
|
+
/** Fired once when the model opens an extended-thinking block — drives a live "thinking"
|
|
72
|
+
* placeholder for signature-only reasoning models (opus-4-7/4-8) that stream no thought text. */
|
|
73
|
+
onReasoningStart?(): void;
|
|
71
74
|
/** Per-artifact native reasoning replay records (signature / thoughtSignature / reasoning
|
|
72
75
|
* item). The TUI ignores these; launch.ts uses them to persist the final reply's artifacts. */
|
|
73
76
|
onReasoningArtifactStream?(artifact: import("../ai/types").ReasoningArtifact): void;
|
|
@@ -247,6 +250,11 @@ export class LaunchTui {
|
|
|
247
250
|
* streams, then persisted once into scrollback as a "Thinking" block on commit so the
|
|
248
251
|
* model's reasoning stays visible above the answer (gjc "think → answer" parity). */
|
|
249
252
|
private streamingThought = "";
|
|
253
|
+
/** True once the model opens an extended-thinking block this step. Signature-only
|
|
254
|
+
* reasoning models (opus-4-7/4-8) stream NO thinking text, so without this flag the
|
|
255
|
+
* live Thinking block never appears and the wait looks frozen. Drives a placeholder
|
|
256
|
+
* Thinking block until real thought/answer text streams. Reset each step / on commit. */
|
|
257
|
+
private thinkingActive = false;
|
|
250
258
|
/** Uniform live-activity text for the live status field (reasoning OR derived fallback). */
|
|
251
259
|
private streamingActivity = "";
|
|
252
260
|
/** Last stream-driven draw (ms epoch) — throttles per-delta repaints to ≤10/s. */
|
|
@@ -410,6 +418,7 @@ export class LaunchTui {
|
|
|
410
418
|
this.retryNotice = null; // a new step starts a fresh model call
|
|
411
419
|
this.streamingReasoning = ""; // fresh model response this step
|
|
412
420
|
this.streamingThought = "";
|
|
421
|
+
this.thinkingActive = false;
|
|
413
422
|
this.streamingActivity = "";
|
|
414
423
|
this.flushedReasoning = "";
|
|
415
424
|
this.flushedThought = "";
|
|
@@ -452,6 +461,14 @@ export class LaunchTui {
|
|
|
452
461
|
this.draw();
|
|
453
462
|
}
|
|
454
463
|
},
|
|
464
|
+
onReasoningStart: () => {
|
|
465
|
+
// The model opened an extended-thinking block. Signature-only reasoning models
|
|
466
|
+
// (opus-4-7/4-8) stream no thinking text, so flag the thinking phase so the live
|
|
467
|
+
// Thinking block renders a placeholder instead of leaving the wait blank.
|
|
468
|
+
if (this.finished || this.thinkingActive) return;
|
|
469
|
+
this.thinkingActive = true;
|
|
470
|
+
this.draw();
|
|
471
|
+
},
|
|
455
472
|
onAssistant: (_raw, invocation) => {
|
|
456
473
|
this.thinking = false; // model replied; now dispatching the tool
|
|
457
474
|
this.retryNotice = null; // the call got through — clear any backoff notice
|
|
@@ -484,6 +501,7 @@ export class LaunchTui {
|
|
|
484
501
|
}
|
|
485
502
|
this.streamingReasoning = "";
|
|
486
503
|
this.streamingThought = "";
|
|
504
|
+
this.thinkingActive = false;
|
|
487
505
|
this.streamingActivity = "";
|
|
488
506
|
if (invocation && invocation.tool !== "done") {
|
|
489
507
|
this.runningTool = true;
|
|
@@ -650,6 +668,18 @@ export class LaunchTui {
|
|
|
650
668
|
this.draw();
|
|
651
669
|
}
|
|
652
670
|
|
|
671
|
+
private livePromptHighlight?: { start: number; end: number; paint: (s: string) => string };
|
|
672
|
+
/** Recolor the active `/command`·`$skill` trigger token inside the mid-turn live
|
|
673
|
+
* input box (idle-prompt parity). Caller supplies code-point offsets into the
|
|
674
|
+
* draft text + a painter; undefined clears it. */
|
|
675
|
+
setLivePromptHighlight(hl?: { start: number; end: number; paint: (s: string) => string }): void {
|
|
676
|
+
if (this.finished) return;
|
|
677
|
+
const a = this.livePromptHighlight, b = hl;
|
|
678
|
+
if (a?.start === b?.start && a?.end === b?.end && (!a) === (!b)) return;
|
|
679
|
+
this.livePromptHighlight = hl;
|
|
680
|
+
this.draw();
|
|
681
|
+
}
|
|
682
|
+
|
|
653
683
|
private livePromptHint: string[] = [];
|
|
654
684
|
/** Mid-turn command/skill preview lines shown above the live input box, so a
|
|
655
685
|
* /command or $skill typed WHILE a turn runs visibly reacts (idle-prompt parity). */
|
|
@@ -672,6 +702,7 @@ export class LaunchTui {
|
|
|
672
702
|
accentShadow: this.theme.color ? accentShadowPaint(this.theme) : undefined,
|
|
673
703
|
placeholder: "Type your next message...",
|
|
674
704
|
maxBodyRows: 2,
|
|
705
|
+
highlight: this.livePromptHighlight,
|
|
675
706
|
});
|
|
676
707
|
if (this.livePromptHint.length === 0) return box;
|
|
677
708
|
const dim = this.theme.color ? chalk.dim : (s: string) => s;
|
|
@@ -925,6 +956,7 @@ export class LaunchTui {
|
|
|
925
956
|
this.lastLedgerKind = null; // fresh turn: no leading spacer before the first ledger line
|
|
926
957
|
this.livePromptInput = ""; // fresh turn: no next-prompt draft yet
|
|
927
958
|
this.livePromptHint = []; // fresh turn: no mid-turn command preview yet
|
|
959
|
+
this.livePromptHighlight = undefined; // fresh turn: no active trigger token
|
|
928
960
|
this.subagentLive = null; // fresh turn: no nested subagent in flight
|
|
929
961
|
this.activityLog.length = 0; // per-turn ring: timestamps are turn-relative
|
|
930
962
|
this.spinner.updateStep(0, this.footer.maxSteps);
|
|
@@ -1382,6 +1414,13 @@ export class LaunchTui {
|
|
|
1382
1414
|
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1383
1415
|
const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
|
|
1384
1416
|
tail.push(...this.renderLiveBlock(liveLabel, liveThink, cols, rows, 6, "Thinking"));
|
|
1417
|
+
} else if (isThinking && this.thinkingActive) {
|
|
1418
|
+
// Signature-only reasoning models (opus-4-7/4-8) open a thinking block but stream no
|
|
1419
|
+
// thought text — show a live placeholder so the wait reads as active thinking, not a
|
|
1420
|
+
// frozen screen. Replaced the instant any real thought/answer text streams (branch above).
|
|
1421
|
+
const liveMs = this.currentStepStartedAt ? Date.now() - this.currentStepStartedAt : undefined;
|
|
1422
|
+
const liveLabel = liveMs !== undefined ? `Thinking · ${(liveMs / 1000).toFixed(1)}s` : "Thinking";
|
|
1423
|
+
tail.push(...this.renderLiveBlock(liveLabel, "(thinking…)", cols, rows, 6, "Thinking"));
|
|
1385
1424
|
}
|
|
1386
1425
|
|
|
1387
1426
|
// 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,11 @@ 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 a contiguous CHARACTER range of the typed text (e.g. the active
|
|
22
|
+
* `/command` or `$skill` trigger token) so the user sees the invocation is
|
|
23
|
+
* recognized as it is typed. Offsets index `Array.from(line)` code points
|
|
24
|
+
* ([start, end)). Ignored for the placeholder and when `color` is false. */
|
|
25
|
+
highlight?: { start: number; end: number; paint: (s: string) => string };
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export interface InputFrame {
|
|
@@ -38,6 +43,7 @@ function wrapWithCursor(
|
|
|
38
43
|
text: string,
|
|
39
44
|
cursor: number,
|
|
40
45
|
width: number,
|
|
46
|
+
highlight?: { start: number; end: number; paint: (s: string) => string },
|
|
41
47
|
): { rows: string[]; row: number; col: number } {
|
|
42
48
|
const rows: string[] = [];
|
|
43
49
|
let cur = "";
|
|
@@ -67,7 +73,8 @@ function wrapWithCursor(
|
|
|
67
73
|
continue;
|
|
68
74
|
}
|
|
69
75
|
if (ch !== "") {
|
|
70
|
-
|
|
76
|
+
const lit = highlight && i >= highlight.start && i < highlight.end;
|
|
77
|
+
cur += lit ? highlight.paint(rendered) : rendered;
|
|
71
78
|
curW += w;
|
|
72
79
|
}
|
|
73
80
|
}
|
|
@@ -95,7 +102,7 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
|
|
|
95
102
|
rows = [placeholder];
|
|
96
103
|
placeholderRow = true;
|
|
97
104
|
} else {
|
|
98
|
-
const wrapped = wrapWithCursor(line, opts.cursor ?? line.length, textWidth);
|
|
105
|
+
const wrapped = wrapWithCursor(line, opts.cursor ?? line.length, textWidth, useColor ? opts.highlight : undefined);
|
|
99
106
|
rows = wrapped.rows;
|
|
100
107
|
crow = wrapped.row;
|
|
101
108
|
ccol = wrapped.col;
|
|
@@ -110,10 +117,10 @@ export function renderInputFrame(line: string, opts: InputBoxOptions = {}): Inpu
|
|
|
110
117
|
hidden = Math.min(Math.max(0, crow - maxBodyRows + 1), totalRows - maxBodyRows);
|
|
111
118
|
if (crow < hidden) hidden = crow; // caret above the window → scroll up to it
|
|
112
119
|
rows = rows.slice(hidden, hidden + maxBodyRows);
|
|
113
|
-
if (hidden > 0) rows[0] = `…${rows[0] ?? ""}
|
|
120
|
+
if (hidden > 0) rows[0] = truncateToWidth(`…${rows[0] ?? ""}`, textWidth);
|
|
114
121
|
if (hidden + maxBodyRows < totalRows) {
|
|
115
122
|
const last = rows.length - 1;
|
|
116
|
-
rows[last] = `${rows[last] ?? ""}
|
|
123
|
+
rows[last] = truncateToWidth(`${rows[last] ?? ""}…`, textWidth);
|
|
117
124
|
}
|
|
118
125
|
}
|
|
119
126
|
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
|
+
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool, type ToolResult } from "./tools";
|
|
2
|
-
|
|
3
|
-
export type ToolHandler = (args: Record<string, any>, cwd: string) => Promise<ToolResult>;
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
6
|
-
read: (a, cwd) => readTool(a.filePath ?? a.path, a.lineRange ?? a.range, cwd, !!a.raw),
|
|
7
|
-
write: (a, cwd) => writeTool(a.filePath ?? a.path, a.content ?? "", cwd),
|
|
8
|
-
edit: (a, cwd) => editTool(a.filePath ?? a.path, a.editBlock ?? a.edit ?? "", cwd),
|
|
9
|
-
bash: (a, cwd) => bashTool(a.command ?? a.cmd, cwd, typeof a.timeoutMs === "number" ? a.timeoutMs : undefined, typeof a.cwd === "string" ? a.cwd : (typeof a.subdir === "string" ? a.subdir : undefined), a.env && typeof a.env === "object" ? a.env : undefined),
|
|
10
|
-
find: (a, cwd) => findTool(a.globPattern ?? a.pattern, cwd),
|
|
11
|
-
search: (a, cwd) => searchTool(a.pattern, a.globPattern ?? "*", cwd, !!(a.ignoreCase ?? a.i), { before: a.before, after: a.after, context: a.context, maxMatches: a.maxMatches }),
|
|
12
|
-
ls: (a, cwd) => lsTool(a.dirPath ?? a.path ?? a.dir ?? ".", cwd),
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export const TOOL_PROTOCOL = [
|
|
16
|
-
"You have these tools (call exactly ONE per step):",
|
|
17
|
-
"1. read {filePath, lineRange?, raw?} — read a file",
|
|
18
|
-
"2. write {filePath, content} — create/overwrite a file",
|
|
19
|
-
"3. edit {filePath, editBlock} — replace/insert lines",
|
|
20
|
-
"4. bash {command, timeoutMs?, cwd?, env?} — run a shell command",
|
|
21
|
-
"5. find {globPattern} — find files by name",
|
|
22
|
-
"6. search {pattern, globPattern?, ignoreCase?, context?, maxMatches?} — grep",
|
|
23
|
-
"7. ls {dirPath} — list a directory",
|
|
24
|
-
"8. done {reason?} — call when done",
|
|
25
|
-
"",
|
|
26
|
-
"Reply with STRICT JSON only:",
|
|
27
|
-
'{ "tool": "<name>", "arguments": { ... } }',
|
|
28
|
-
].join("\n");
|
|
29
|
-
|
|
30
|
-
export const READONLY_TOOL_PROTOCOL = [
|
|
31
|
-
"You have these READ-ONLY tools:",
|
|
32
|
-
"1. read {filePath, lineRange?} — read a file",
|
|
33
|
-
"2. find {globPattern} — find files by name",
|
|
34
|
-
"3. search {pattern, globPattern?, ignoreCase?} — grep",
|
|
35
|
-
"4. ls {dirPath} — list a directory",
|
|
36
|
-
"5. done {reason?} — call when complete",
|
|
37
|
-
"",
|
|
38
|
-
"Reply with STRICT JSON only:",
|
|
39
|
-
'{ "tool": "<name>", "arguments": { ... } }',
|
|
40
|
-
].join("\n");
|
|
41
|
-
|
|
42
|
-
export function nearestToolName(name: string, known: string[]): string | undefined {
|
|
43
|
-
const want = name.trim().toLowerCase();
|
|
44
|
-
if (!want) return undefined;
|
|
45
|
-
let best: string | undefined;
|
|
46
|
-
let bestD = Infinity;
|
|
47
|
-
for (const k of known) {
|
|
48
|
-
const kl = k.toLowerCase();
|
|
49
|
-
if (kl === want) return k;
|
|
50
|
-
const d = kl.startsWith(want) || want.startsWith(kl) ? 1 : 10;
|
|
51
|
-
if (d < bestD) { bestD = d; best = k; }
|
|
52
|
-
}
|
|
53
|
-
return bestD <= 2 ? best : undefined;
|
|
54
|
-
}
|