@giovannijecha/jecode 0.6.0 → 0.7.1
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/README.md +242 -256
- package/dist/batch.js +5 -1
- package/dist/commands.js +38 -10
- package/dist/config.js +46 -21
- package/dist/context/compactor.js +3 -1
- package/dist/context/manual.js +69 -0
- package/dist/credential-safety.js +9 -5
- package/dist/providers/anthropic-wire.js +5 -4
- package/dist/providers/ollama-wire.js +3 -2
- package/dist/providers/openai-wire.js +6 -5
- package/dist/providers/wire-usage.js +6 -0
- package/dist/settings-command.js +1 -1
- package/dist/timeline.js +90 -0
- package/dist/tui/app-state.js +1 -0
- package/dist/tui/app-workflows.js +30 -4
- package/dist/tui/app.js +68 -39
- package/dist/tui/components/misc.js +4 -2
- package/dist/tui/picker.js +2 -2
- package/dist/ui/terminal-text.js +4 -1
- package/package.json +1 -1
package/dist/commands.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Slash-command registry and dispatcher.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// this file keeps discovery, dispatch, and local session operations.
|
|
3
|
+
// Model discovery and explicit context compaction can reach a provider, but
|
|
4
|
+
// slash commands never become user messages. This file keeps discovery,
|
|
5
|
+
// dispatch, and session operations outside the canonical transcript.
|
|
7
6
|
import { ConversationTree } from "./conversation.js";
|
|
8
7
|
import { modelsCommand } from "./model-command.js";
|
|
9
8
|
import { providersCommand } from "./provider-commands.js";
|
|
@@ -22,6 +21,8 @@ export const COMMANDS = [
|
|
|
22
21
|
{ name: "exit", blurb: "exit and restore the terminal" },
|
|
23
22
|
{ name: "new", blurb: "start clean and reset tool permissions" },
|
|
24
23
|
{ name: "export", blurb: "save this transcript as Markdown" },
|
|
24
|
+
{ name: "timeline", blurb: "navigate this conversation tree" },
|
|
25
|
+
{ name: "compact", blurb: "compact the active context now" },
|
|
25
26
|
{ name: "permissions", blurb: "manage session tool access" },
|
|
26
27
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
27
28
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
@@ -51,6 +52,39 @@ export async function handleCommand(line, session, host) {
|
|
|
51
52
|
session.usage = emptyUsage();
|
|
52
53
|
host.emit({ kind: "notice", text: "new session", tone: "info" });
|
|
53
54
|
return "handled";
|
|
55
|
+
case "timeline": {
|
|
56
|
+
if (host.timeline === undefined) {
|
|
57
|
+
host.emit({ kind: "notice", text: "timeline needs the interactive screen", tone: "warn" });
|
|
58
|
+
return "handled";
|
|
59
|
+
}
|
|
60
|
+
const result = await host.timeline();
|
|
61
|
+
if (result === "selected") {
|
|
62
|
+
host.emit({
|
|
63
|
+
kind: "notice",
|
|
64
|
+
text: "branch point selected · send a message to continue",
|
|
65
|
+
tone: "info",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return "handled";
|
|
69
|
+
}
|
|
70
|
+
case "compact": {
|
|
71
|
+
if (host.compact === undefined) {
|
|
72
|
+
host.emit({ kind: "notice", text: "compact is unavailable here", tone: "warn" });
|
|
73
|
+
return "handled";
|
|
74
|
+
}
|
|
75
|
+
const result = await host.compact();
|
|
76
|
+
if (result === "compacted") {
|
|
77
|
+
host.emit({ kind: "notice", text: "context compacted", tone: "info" });
|
|
78
|
+
}
|
|
79
|
+
else if (result === "branch-pending") {
|
|
80
|
+
host.emit({
|
|
81
|
+
kind: "notice",
|
|
82
|
+
text: "send a message on this branch before compacting",
|
|
83
|
+
tone: "warn",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return "handled";
|
|
87
|
+
}
|
|
54
88
|
case "export":
|
|
55
89
|
if (host.exportTranscript === undefined) {
|
|
56
90
|
host.emit({ kind: "notice", text: "export needs the interactive screen", tone: "warn" });
|
|
@@ -84,9 +118,3 @@ export async function handleCommand(line, session, host) {
|
|
|
84
118
|
return "handled";
|
|
85
119
|
}
|
|
86
120
|
}
|
|
87
|
-
function chooser(host) {
|
|
88
|
-
if (host.choose === undefined) {
|
|
89
|
-
host.emit({ kind: "notice", text: "that command needs the screen", tone: "warn" });
|
|
90
|
-
}
|
|
91
|
-
return host.choose;
|
|
92
|
-
}
|
package/dist/config.js
CHANGED
|
@@ -3,29 +3,24 @@ import * as path from "node:path";
|
|
|
3
3
|
import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
|
|
4
4
|
import { EFFORTS, readSettings } from "./settings.js";
|
|
5
5
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
6
|
-
const
|
|
6
|
+
const VALUE_FLAGS = [
|
|
7
7
|
"provider",
|
|
8
8
|
"model",
|
|
9
9
|
"ollama-host",
|
|
10
|
-
"reduced-motion",
|
|
11
10
|
"effort",
|
|
12
11
|
"max-tokens",
|
|
13
12
|
"max-steps",
|
|
14
13
|
"compaction-percent",
|
|
15
14
|
"root",
|
|
15
|
+
];
|
|
16
|
+
const BOOLEAN_FLAGS = [
|
|
17
|
+
"reduced-motion",
|
|
16
18
|
"auto-approve",
|
|
17
19
|
"ephemeral",
|
|
18
20
|
];
|
|
21
|
+
const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
|
|
19
22
|
export function loadConfig(argv, saved = readSettings()) {
|
|
20
23
|
const flags = parseFlags(argv);
|
|
21
|
-
// A flag nobody declared is a typo. Swallowed in silence it becomes a
|
|
22
|
-
// setting the user believes is on, and the run that ignores it looks like
|
|
23
|
-
// the feature is broken rather than misspelled.
|
|
24
|
-
for (const name of Object.keys(flags)) {
|
|
25
|
-
if (!FLAGS.includes(name)) {
|
|
26
|
-
throw new Error(`unknown flag --${name} (known: ${FLAGS.map((f) => `--${f}`).join(", ")})`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
24
|
const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
|
|
30
25
|
const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
|
|
31
26
|
const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
|
|
@@ -45,7 +40,9 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
45
40
|
maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
|
|
46
41
|
compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
|
|
47
42
|
root: path.resolve(pick(flags.root, undefined, process.cwd())),
|
|
48
|
-
autoApprove: flags["auto-approve"] === "true" ||
|
|
43
|
+
autoApprove: flags["auto-approve"] === "true" ||
|
|
44
|
+
flags["auto-approve"] === "1" ||
|
|
45
|
+
process.env.JECODE_AUTO_APPROVE === "1",
|
|
49
46
|
ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
|
|
50
47
|
};
|
|
51
48
|
}
|
|
@@ -85,27 +82,55 @@ function toPercent(value) {
|
|
|
85
82
|
}
|
|
86
83
|
return percent;
|
|
87
84
|
}
|
|
88
|
-
//
|
|
85
|
+
// Value flags accept --key value and --key=value. Boolean flags are bare or
|
|
86
|
+
// take a real boolean value, so an accidental positional argument is never
|
|
87
|
+
// swallowed as configuration.
|
|
89
88
|
function parseFlags(argv) {
|
|
90
89
|
const flags = {};
|
|
91
90
|
for (let i = 0; i < argv.length; i++) {
|
|
92
91
|
const arg = argv[i];
|
|
93
|
-
if (arg === undefined
|
|
92
|
+
if (arg === undefined)
|
|
94
93
|
continue;
|
|
94
|
+
if (!arg.startsWith("--"))
|
|
95
|
+
throw new Error(`unexpected argument "${arg}"`);
|
|
95
96
|
const body = arg.slice(2);
|
|
96
97
|
const eq = body.indexOf("=");
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
99
|
+
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
100
|
+
if (!FLAGS.includes(name)) {
|
|
101
|
+
throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
|
|
102
|
+
}
|
|
103
|
+
if (BOOLEAN_FLAGS.includes(name)) {
|
|
104
|
+
if (inline === undefined) {
|
|
105
|
+
const next = argv[i + 1];
|
|
106
|
+
if (next !== undefined && ["true", "false", "1", "0"].includes(next)) {
|
|
107
|
+
flags[name] = next;
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
flags[name] = "true";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (["true", "false", "1", "0"].includes(inline)) {
|
|
115
|
+
flags[name] = inline;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
throw new Error(`--${name} must be true or false`);
|
|
119
|
+
}
|
|
99
120
|
continue;
|
|
100
121
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
if (inline !== undefined) {
|
|
123
|
+
if (inline === "")
|
|
124
|
+
throw new Error(`--${name} requires a value`);
|
|
125
|
+
flags[name] = inline;
|
|
126
|
+
continue;
|
|
105
127
|
}
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
const next = argv[i + 1];
|
|
129
|
+
if (next === undefined || next.startsWith("--")) {
|
|
130
|
+
throw new Error(`--${name} requires a value`);
|
|
108
131
|
}
|
|
132
|
+
flags[name] = next;
|
|
133
|
+
i++;
|
|
109
134
|
}
|
|
110
135
|
return flags;
|
|
111
136
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Explicit compaction of the selected durable leaf.
|
|
2
|
+
//
|
|
3
|
+
// The canonical messages and transcript remain untouched. Only the active
|
|
4
|
+
// leaf receives a new branch-local context anchor, using the same provider
|
|
5
|
+
// policy and summarizer as automatic compaction.
|
|
6
|
+
import { recordAuxiliaryUsage } from "../usage.js";
|
|
7
|
+
import { resolveContextPolicy } from "./capacity.js";
|
|
8
|
+
import { compactContext } from "./compactor.js";
|
|
9
|
+
import { estimateTokens, planCompaction } from "./policy.js";
|
|
10
|
+
const MIN_PREFIX_TOKENS = 512;
|
|
11
|
+
export async function compactSession(session, options = {}) {
|
|
12
|
+
const active = session.conversation.activeNode;
|
|
13
|
+
if (active === undefined)
|
|
14
|
+
return "unchanged";
|
|
15
|
+
const context = session.conversation.contextHistory;
|
|
16
|
+
if (estimateTokens(context) < MIN_PREFIX_TOKENS)
|
|
17
|
+
return "unchanged";
|
|
18
|
+
if (session.conversation.nodes.some((node) => node.parentId === active.id)) {
|
|
19
|
+
throw new Error("continue this branch before compacting");
|
|
20
|
+
}
|
|
21
|
+
options.onStatus?.("Checking context");
|
|
22
|
+
const policy = await resolveContextPolicy({
|
|
23
|
+
provider: session.provider,
|
|
24
|
+
model: session.model,
|
|
25
|
+
compactionPercent: session.config.compactionPercent,
|
|
26
|
+
signal: options.signal,
|
|
27
|
+
onStatus: (status) => options.onStatus?.(status),
|
|
28
|
+
});
|
|
29
|
+
const coveredMessages = active.context?.throughNodeId === active.id
|
|
30
|
+
? active.context.messageCount
|
|
31
|
+
: 0;
|
|
32
|
+
const plan = planCompaction(context, active.messages, coveredMessages, session.usage.lastInputTokens, true, policy);
|
|
33
|
+
if (plan === undefined || estimateTokens(plan.prefix) < MIN_PREFIX_TOKENS) {
|
|
34
|
+
options.onStatus?.();
|
|
35
|
+
return "unchanged";
|
|
36
|
+
}
|
|
37
|
+
const result = await compactContext({
|
|
38
|
+
provider: session.provider,
|
|
39
|
+
model: session.model,
|
|
40
|
+
effort: session.config.effort,
|
|
41
|
+
context,
|
|
42
|
+
turn: active.messages,
|
|
43
|
+
nodeId: active.id,
|
|
44
|
+
coveredMessages,
|
|
45
|
+
lastInputTokens: session.usage.lastInputTokens,
|
|
46
|
+
signal: options.signal,
|
|
47
|
+
force: true,
|
|
48
|
+
failLoudly: true,
|
|
49
|
+
policy,
|
|
50
|
+
onBegin: () => options.onStatus?.("Compacting"),
|
|
51
|
+
onEnd: () => options.onStatus?.(),
|
|
52
|
+
});
|
|
53
|
+
if (result === undefined)
|
|
54
|
+
throw new Error("context could not be compacted");
|
|
55
|
+
if (result.usage !== undefined)
|
|
56
|
+
recordAuxiliaryUsage(session.usage, result.usage);
|
|
57
|
+
const next = session.conversation.commit({
|
|
58
|
+
nodeId: active.id,
|
|
59
|
+
parentId: active.parentId,
|
|
60
|
+
createdAt: active.createdAt,
|
|
61
|
+
identity: active.identity,
|
|
62
|
+
messages: active.messages,
|
|
63
|
+
blocks: active.blocks,
|
|
64
|
+
context: result.anchor,
|
|
65
|
+
}, active.settlement);
|
|
66
|
+
await session.persistence?.checkpoint(next);
|
|
67
|
+
session.conversation = next;
|
|
68
|
+
return "compacted";
|
|
69
|
+
}
|
|
@@ -40,17 +40,21 @@ export function credentialRedactor(source = process.env) {
|
|
|
40
40
|
const ready = [];
|
|
41
41
|
let at = 0;
|
|
42
42
|
while (at < combined.length) {
|
|
43
|
+
const rest = combined.slice(at);
|
|
44
|
+
// A complete shorter credential can also be the prefix of a longer
|
|
45
|
+
// one. Hold that ambiguous suffix until the next chunk proves which
|
|
46
|
+
// value arrived, otherwise the longer credential leaks its tail.
|
|
47
|
+
if (rest.length < longest &&
|
|
48
|
+
values.some((value) => value.length > rest.length && value.startsWith(rest))) {
|
|
49
|
+
pending = rest;
|
|
50
|
+
return ready.join("");
|
|
51
|
+
}
|
|
43
52
|
const complete = values.find((value) => combined.startsWith(value, at));
|
|
44
53
|
if (complete !== undefined) {
|
|
45
54
|
ready.push(REDACTED);
|
|
46
55
|
at += complete.length;
|
|
47
56
|
continue;
|
|
48
57
|
}
|
|
49
|
-
const rest = combined.slice(at);
|
|
50
|
-
if (rest.length < longest && values.some((value) => value.startsWith(rest))) {
|
|
51
|
-
pending = rest;
|
|
52
|
-
return ready.join("");
|
|
53
|
-
}
|
|
54
58
|
ready.push(combined[at]);
|
|
55
59
|
at++;
|
|
56
60
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the Anthropic wire shape.
|
|
2
2
|
// Pure functions, no I/O — which is what makes them testable without a key.
|
|
3
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
3
4
|
export function toWireTool(tool) {
|
|
4
5
|
return { name: tool.name, description: tool.description, input_schema: tool.input };
|
|
5
6
|
}
|
|
@@ -70,10 +71,10 @@ function normalizeUsage(data) {
|
|
|
70
71
|
if (usage === undefined)
|
|
71
72
|
return undefined;
|
|
72
73
|
return {
|
|
73
|
-
inputTokens: usage.input_tokens
|
|
74
|
-
outputTokens: usage.output_tokens
|
|
75
|
-
cachedInputTokens: usage.cache_read_input_tokens
|
|
76
|
-
cacheWriteInputTokens: usage.cache_creation_input_tokens
|
|
74
|
+
inputTokens: wireTokenCount(usage.input_tokens),
|
|
75
|
+
outputTokens: wireTokenCount(usage.output_tokens),
|
|
76
|
+
cachedInputTokens: wireTokenCount(usage.cache_read_input_tokens),
|
|
77
|
+
cacheWriteInputTokens: wireTokenCount(usage.cache_creation_input_tokens),
|
|
77
78
|
reasoningTokens: 0,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Chat Completions differs on two points that matter here — a tool result is a
|
|
7
7
|
// message of its own with role "tool", not a block inside a user turn, and tool
|
|
8
8
|
// arguments travel as a JSON string rather than an object.
|
|
9
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
9
10
|
export function toWireTool(tool) {
|
|
10
11
|
return {
|
|
11
12
|
type: "function",
|
|
@@ -83,8 +84,8 @@ function normalizeUsage(reply) {
|
|
|
83
84
|
if (reply.usage === undefined)
|
|
84
85
|
return undefined;
|
|
85
86
|
return {
|
|
86
|
-
inputTokens: reply.usage.prompt_tokens
|
|
87
|
-
outputTokens: reply.usage.completion_tokens
|
|
87
|
+
inputTokens: wireTokenCount(reply.usage.prompt_tokens),
|
|
88
|
+
outputTokens: wireTokenCount(reply.usage.completion_tokens),
|
|
88
89
|
cachedInputTokens: 0,
|
|
89
90
|
cacheWriteInputTokens: 0,
|
|
90
91
|
reasoningTokens: 0,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the OpenAI Responses wire
|
|
2
2
|
// shape: a flat `input` list where tool calls and their outputs are top-level
|
|
3
3
|
// items keyed by `call_id`, rather than blocks nested inside a message.
|
|
4
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
4
5
|
export function toWireTool(tool) {
|
|
5
6
|
return {
|
|
6
7
|
type: "function",
|
|
@@ -83,11 +84,11 @@ function normalizeUsage(data) {
|
|
|
83
84
|
if (usage === undefined || usage === null)
|
|
84
85
|
return undefined;
|
|
85
86
|
return {
|
|
86
|
-
inputTokens: usage.input_tokens
|
|
87
|
-
outputTokens: usage.output_tokens
|
|
88
|
-
cachedInputTokens: usage.input_tokens_details?.cached_tokens
|
|
89
|
-
cacheWriteInputTokens: usage.input_tokens_details?.cache_write_tokens
|
|
90
|
-
reasoningTokens: usage.output_tokens_details?.reasoning_tokens
|
|
87
|
+
inputTokens: wireTokenCount(usage.input_tokens),
|
|
88
|
+
outputTokens: wireTokenCount(usage.output_tokens),
|
|
89
|
+
cachedInputTokens: wireTokenCount(usage.input_tokens_details?.cached_tokens),
|
|
90
|
+
cacheWriteInputTokens: wireTokenCount(usage.input_tokens_details?.cache_write_tokens),
|
|
91
|
+
reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
// Arguments arrive as a JSON string and models vary in how they escape it, so
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Usage comes from remote JSON and eventually reaches the strict session
|
|
2
|
+
// codec. Normalize it at the wire boundary so one malformed counter cannot
|
|
3
|
+
// make an otherwise valid saved session unreadable.
|
|
4
|
+
export function wireTokenCount(value) {
|
|
5
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
6
|
+
}
|
package/dist/settings-command.js
CHANGED
|
@@ -202,7 +202,7 @@ async function compactionSetting(session, host) {
|
|
|
202
202
|
const label = "context compaction";
|
|
203
203
|
const field = {
|
|
204
204
|
title: heading(label, `${MIN_COMPACTION_PERCENT}-${MAX_COMPACTION_PERCENT} percent`, session.palette),
|
|
205
|
-
right: "enter save
|
|
205
|
+
right: "enter save · esc back",
|
|
206
206
|
editor: of(String(session.config.compactionPercent)),
|
|
207
207
|
secret: false,
|
|
208
208
|
note: "Compacts when model context reaches this percentage.",
|
package/dist/timeline.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// A compact, read-only projection of the durable conversation tree.
|
|
2
|
+
//
|
|
3
|
+
// Selecting a node changes only the in-memory path. The next real user turn
|
|
4
|
+
// is what persists a branch, so opening or cancelling this control plane can
|
|
5
|
+
// never create empty history.
|
|
6
|
+
import { usageFromHistory } from "./usage.js";
|
|
7
|
+
import { heading } from "./tui/picker.js";
|
|
8
|
+
export function timelinePicker(conversation, palette) {
|
|
9
|
+
const entries = timelineEntries(conversation);
|
|
10
|
+
const selectedId = conversation.latestCompleted()?.activeNodeId ?? 0;
|
|
11
|
+
const index = Math.max(0, entries.findIndex((entry) => entry.node.id === selectedId));
|
|
12
|
+
return Object.freeze({
|
|
13
|
+
picker: {
|
|
14
|
+
title: heading("timeline", "conversation tree", palette),
|
|
15
|
+
searchable: true,
|
|
16
|
+
query: "",
|
|
17
|
+
visible: 8,
|
|
18
|
+
options: entries.map((entry) => ({
|
|
19
|
+
label: `${entry.prefix}${preview(entry.node)}`,
|
|
20
|
+
hint: stamp(entry.node.createdAt),
|
|
21
|
+
...(entry.node.id === selectedId ? { value: "active" } : {}),
|
|
22
|
+
})),
|
|
23
|
+
index,
|
|
24
|
+
},
|
|
25
|
+
nodeIds: Object.freeze(entries.map((entry) => entry.node.id)),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export async function selectTimeline(session, choose) {
|
|
29
|
+
const timeline = timelinePicker(session.conversation, session.palette);
|
|
30
|
+
if (timeline.nodeIds.length === 0)
|
|
31
|
+
return false;
|
|
32
|
+
const index = await choose(timeline.picker);
|
|
33
|
+
const nodeId = index === undefined ? undefined : timeline.nodeIds[index];
|
|
34
|
+
if (nodeId === undefined || nodeId === session.conversation.activeNodeId)
|
|
35
|
+
return false;
|
|
36
|
+
session.conversation = session.conversation.select(nodeId);
|
|
37
|
+
session.usage = usageFromHistory(session.conversation.history);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function timelineEntries(conversation) {
|
|
41
|
+
const completed = conversation.nodes.filter((node) => node.settlement === "completed");
|
|
42
|
+
const completedIds = new Set(completed.map((node) => node.id));
|
|
43
|
+
const children = new Map();
|
|
44
|
+
for (const node of completed) {
|
|
45
|
+
let parentId = node.parentId;
|
|
46
|
+
while (parentId !== 0 && !completedIds.has(parentId)) {
|
|
47
|
+
parentId = conversation.node(parentId)?.parentId ?? 0;
|
|
48
|
+
}
|
|
49
|
+
const siblings = children.get(parentId) ?? [];
|
|
50
|
+
siblings.push(node);
|
|
51
|
+
children.set(parentId, siblings);
|
|
52
|
+
}
|
|
53
|
+
const entries = [];
|
|
54
|
+
const visit = (parentId, lanes) => {
|
|
55
|
+
const siblings = children.get(parentId) ?? [];
|
|
56
|
+
for (let index = 0; index < siblings.length; index++) {
|
|
57
|
+
const node = siblings[index];
|
|
58
|
+
const forks = siblings.length > 1;
|
|
59
|
+
const last = index === siblings.length - 1;
|
|
60
|
+
const hidden = Math.max(0, lanes.length - 4);
|
|
61
|
+
const lanePrefix = `${hidden === 0 ? "" : "… "}${lanes.slice(hidden)
|
|
62
|
+
.map((closed) => closed ? " " : "│ ").join("")}`;
|
|
63
|
+
entries.push({
|
|
64
|
+
node,
|
|
65
|
+
prefix: `${lanePrefix}${forks ? (last ? "└─ " : "├─ ") : "• "}`,
|
|
66
|
+
});
|
|
67
|
+
visit(node.id, forks ? [...lanes, last] : lanes);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
visit(0, []);
|
|
71
|
+
return entries;
|
|
72
|
+
}
|
|
73
|
+
function preview(node) {
|
|
74
|
+
for (const message of node.messages) {
|
|
75
|
+
if (message.role !== "user")
|
|
76
|
+
continue;
|
|
77
|
+
const text = message.content.find((block) => block.kind === "text")?.text
|
|
78
|
+
.replace(/\s+/gu, " ").trim();
|
|
79
|
+
if (text !== undefined && text !== "")
|
|
80
|
+
return text.slice(0, 160);
|
|
81
|
+
}
|
|
82
|
+
return "Untitled turn";
|
|
83
|
+
}
|
|
84
|
+
function stamp(value) {
|
|
85
|
+
const date = new Date(value);
|
|
86
|
+
if (!Number.isFinite(date.getTime()))
|
|
87
|
+
return value.replace("T", " ").slice(0, 16);
|
|
88
|
+
const two = (part) => String(part).padStart(2, "0");
|
|
89
|
+
return `${two(date.getHours())}:${two(date.getMinutes())}`;
|
|
90
|
+
}
|
package/dist/tui/app-state.js
CHANGED
|
@@ -3,10 +3,12 @@ import { handleCommand } from "../commands.js";
|
|
|
3
3
|
import { runTurn } from "../controller.js";
|
|
4
4
|
import { resolveContextPolicy } from "../context/capacity.js";
|
|
5
5
|
import { compactContext } from "../context/compactor.js";
|
|
6
|
+
import { compactSession } from "../context/manual.js";
|
|
6
7
|
import { isContextOverflow, shouldResolveContextPolicy } from "../context/policy.js";
|
|
7
8
|
import { updateSettings } from "../settings.js";
|
|
8
9
|
import { saveTranscript } from "../transcript-export.js";
|
|
9
10
|
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
11
|
+
import { selectTimeline } from "../timeline.js";
|
|
10
12
|
import { answerAt } from "./approve.js";
|
|
11
13
|
import { cancel as cancelOpen } from "./overlay.js";
|
|
12
14
|
import { controllerOptions, turnFailure } from "./session-view.js";
|
|
@@ -14,6 +16,10 @@ import { transcribe } from "./turn.js";
|
|
|
14
16
|
const WAITING = "Waiting";
|
|
15
17
|
export function appWorkflows(options) {
|
|
16
18
|
const { session, state, permissions, feedback } = options;
|
|
19
|
+
const choose = (picker) => new Promise((resolve) => {
|
|
20
|
+
state.open = { picker, settle: resolve };
|
|
21
|
+
options.render();
|
|
22
|
+
});
|
|
17
23
|
async function command(text) {
|
|
18
24
|
const activity = options.startActivity("command", `Running ${text.split(/\s+/)[0]}`);
|
|
19
25
|
if (activity === undefined)
|
|
@@ -26,10 +32,7 @@ export function appWorkflows(options) {
|
|
|
26
32
|
state.open = { help: true, settle: resolve };
|
|
27
33
|
options.render();
|
|
28
34
|
}),
|
|
29
|
-
choose
|
|
30
|
-
state.open = { picker, settle: resolve };
|
|
31
|
-
options.render();
|
|
32
|
-
}),
|
|
35
|
+
choose,
|
|
33
36
|
dismiss: () => {
|
|
34
37
|
state.open = state.open === undefined ? undefined : cancelOpen(state.open);
|
|
35
38
|
options.render();
|
|
@@ -51,6 +54,7 @@ export function appWorkflows(options) {
|
|
|
51
54
|
state.follow = true;
|
|
52
55
|
state.unseen = 0;
|
|
53
56
|
state.lastMaxScroll = 0;
|
|
57
|
+
state.committedNodeId = 0;
|
|
54
58
|
},
|
|
55
59
|
permissions,
|
|
56
60
|
exportTranscript: () => saveTranscript(options.transcriptRoot, state.blocks),
|
|
@@ -58,6 +62,27 @@ export function appWorkflows(options) {
|
|
|
58
62
|
await updateSettings(patch);
|
|
59
63
|
},
|
|
60
64
|
refreshSettings: options.refreshSettings,
|
|
65
|
+
timeline: async () => {
|
|
66
|
+
const selected = await selectTimeline(session, choose);
|
|
67
|
+
if (!selected)
|
|
68
|
+
return "unchanged";
|
|
69
|
+
options.replaceTranscript();
|
|
70
|
+
return session.conversation.activeNodeId === state.committedNodeId
|
|
71
|
+
? "unchanged"
|
|
72
|
+
: "selected";
|
|
73
|
+
},
|
|
74
|
+
compact: async () => {
|
|
75
|
+
if (session.conversation.activeNodeId !== state.committedNodeId) {
|
|
76
|
+
return "branch-pending";
|
|
77
|
+
}
|
|
78
|
+
return compactSession(session, {
|
|
79
|
+
signal: activity.control.signal,
|
|
80
|
+
onStatus: (status) => {
|
|
81
|
+
state.status = status ?? activity.label;
|
|
82
|
+
options.render();
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
},
|
|
61
86
|
});
|
|
62
87
|
if (outcome === "exit")
|
|
63
88
|
state.closeWhenIdle = true;
|
|
@@ -128,6 +153,7 @@ export function appWorkflows(options) {
|
|
|
128
153
|
await session.persistence?.checkpoint(next);
|
|
129
154
|
session.conversation = next;
|
|
130
155
|
nodeId = next.activeNodeId;
|
|
156
|
+
state.committedNodeId = next.activeNodeId;
|
|
131
157
|
};
|
|
132
158
|
const compact = async (checkpoint, projected, reason, error) => {
|
|
133
159
|
if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
|