tinker-agent 2.13.0 → 3.0.0
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 +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { readPromptHistory, appendPromptHistory } from "./prompt-history";
|
|
2
|
+
import { loadProjectSlashCommands } from "../tui/project-slash-commands";
|
|
3
|
+
import { readCurrentGitBranch } from "../tui/git-branch";
|
|
4
|
+
import { listMemoryFiles } from "../memory/memory-files";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { realpath } from "node:fs/promises";
|
|
7
|
+
import type { HostedSession } from "../agent/runtime-hosted-session";
|
|
8
|
+
import { requireObject, requireText, RemoteError } from "./protocol";
|
|
9
|
+
import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
|
|
10
|
+
import { validateImageAssetRef, type ImageAssetRef } from "../image/image-types";
|
|
11
|
+
import { encodeFailure } from "./failures";
|
|
12
|
+
import { createWorkspaceFileLister } from "../tui/workspace-file-search";
|
|
13
|
+
import { loadViewFile } from "../tui/view-file";
|
|
14
|
+
import { readLastAssistantResponse } from "../session/session-last-response-reader";
|
|
15
|
+
|
|
16
|
+
export async function sessionPost(
|
|
17
|
+
session: HostedSession,
|
|
18
|
+
route: string,
|
|
19
|
+
request: Request,
|
|
20
|
+
homeRoot?: string,
|
|
21
|
+
): Promise<Response | undefined> {
|
|
22
|
+
if (!["images", "verify-images", "prompt-history"].includes(route)) return undefined;
|
|
23
|
+
try {
|
|
24
|
+
if (route === "prompt-history") {
|
|
25
|
+
await appendPromptHistory(
|
|
26
|
+
request,
|
|
27
|
+
session.tuiSnapshot().history.workspaceRoot,
|
|
28
|
+
homeRoot,
|
|
29
|
+
);
|
|
30
|
+
return Response.json({ saved: true });
|
|
31
|
+
}
|
|
32
|
+
const input = requireObject(await request.json());
|
|
33
|
+
const value = await session.exclusive(async (runtime) => {
|
|
34
|
+
if (route === "verify-images") {
|
|
35
|
+
if (
|
|
36
|
+
!Array.isArray(input.assets) ||
|
|
37
|
+
input.assets.length > IMAGE_INPUT_POLICY.maxImagesPerMessage
|
|
38
|
+
)
|
|
39
|
+
throw new Error("Invalid image asset list.");
|
|
40
|
+
for (const asset of input.assets) validateImageAssetRef(asset as ImageAssetRef);
|
|
41
|
+
await runtime.verifyImageAssets(
|
|
42
|
+
input.assets as ImageAssetRef[],
|
|
43
|
+
request.signal,
|
|
44
|
+
);
|
|
45
|
+
return { verified: true };
|
|
46
|
+
}
|
|
47
|
+
const count = input.count;
|
|
48
|
+
if (
|
|
49
|
+
typeof count !== "number" ||
|
|
50
|
+
!Number.isInteger(count) ||
|
|
51
|
+
count < 1 ||
|
|
52
|
+
count > IMAGE_INPUT_POLICY.maxImagesPerMessage
|
|
53
|
+
)
|
|
54
|
+
throw new Error("Invalid image count.");
|
|
55
|
+
if (input.sourcePath !== undefined) {
|
|
56
|
+
const source = requireText(input.sourcePath, "sourcePath", 4096);
|
|
57
|
+
if (path.isAbsolute(source))
|
|
58
|
+
throw new Error("Workspace image paths must be relative.");
|
|
59
|
+
return runtime.importImage(source, request.signal, count);
|
|
60
|
+
}
|
|
61
|
+
const encoded = requireText(
|
|
62
|
+
input.bytes,
|
|
63
|
+
"bytes",
|
|
64
|
+
Math.ceil(IMAGE_INPUT_POLICY.maxBytesPerImage / 3) * 4,
|
|
65
|
+
);
|
|
66
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.length % 4 !== 0)
|
|
67
|
+
throw new Error("Invalid base64 image.");
|
|
68
|
+
return runtime.importImageBytes(
|
|
69
|
+
Buffer.from(encoded, "base64"),
|
|
70
|
+
requireText(input.originalName, "originalName", 255),
|
|
71
|
+
request.signal,
|
|
72
|
+
count,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
return Response.json(value, { headers: { "Cache-Control": "no-store" } });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return Response.json(
|
|
78
|
+
{
|
|
79
|
+
error: { message: error instanceof Error ? error.message : String(error) },
|
|
80
|
+
failure: encodeFailure(error),
|
|
81
|
+
},
|
|
82
|
+
{ status: error instanceof RemoteError ? error.status : 422 },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
export async function sessionRead(
|
|
87
|
+
session: HostedSession,
|
|
88
|
+
route: string,
|
|
89
|
+
url: URL,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
homeRoot?: string,
|
|
92
|
+
): Promise<unknown> {
|
|
93
|
+
const snapshot = session.tuiSnapshot();
|
|
94
|
+
const workspaceRoot = snapshot.history.workspaceRoot;
|
|
95
|
+
if (route === "prompt-history") return readPromptHistory(workspaceRoot, homeRoot);
|
|
96
|
+
if (route === "project-commands") return loadProjectSlashCommands(workspaceRoot);
|
|
97
|
+
if (route === "git-branch")
|
|
98
|
+
return { branch: await readCurrentGitBranch(workspaceRoot) };
|
|
99
|
+
if (route === "memories") return listMemoryFiles(homeRoot);
|
|
100
|
+
if (route === "files") return createWorkspaceFileLister()(workspaceRoot, signal);
|
|
101
|
+
if (route === "last-response")
|
|
102
|
+
return {
|
|
103
|
+
text: await readLastAssistantResponse({
|
|
104
|
+
workspaceRoot,
|
|
105
|
+
sessionId: session.id,
|
|
106
|
+
homeRoot,
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
if (route === "view-file") {
|
|
110
|
+
const requested = requireText(url.searchParams.get("path"), "path", 4096);
|
|
111
|
+
const actual = await realpath(path.resolve(workspaceRoot, requested));
|
|
112
|
+
const relative = path.relative(workspaceRoot, actual);
|
|
113
|
+
if (
|
|
114
|
+
relative === ".." ||
|
|
115
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
116
|
+
path.isAbsolute(relative)
|
|
117
|
+
)
|
|
118
|
+
throw new RemoteError(
|
|
119
|
+
403,
|
|
120
|
+
"OUTSIDE_WORKSPACE",
|
|
121
|
+
"File is outside this workspace.",
|
|
122
|
+
);
|
|
123
|
+
return loadViewFile(workspaceRoot, relative);
|
|
124
|
+
}
|
|
125
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown session read route.");
|
|
126
|
+
}
|
package/src/remote/sync-hub.ts
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ClientModelCatalog } from "../client/model-catalog";
|
|
2
|
+
import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
|
|
3
|
+
import type { ProviderRetrySnapshot } from "../agent/runtime-provider-retry";
|
|
4
|
+
import type { AskUserSnapshot } from "../agent/runtime-session";
|
|
5
|
+
import type { PromptSchedulerSnapshot } from "../agent/runtime-session-contracts";
|
|
6
|
+
import type { TuiTimelineLog } from "../tui/tui-projection-store";
|
|
7
|
+
import type {
|
|
8
|
+
BashGuardSnapshot,
|
|
9
|
+
RuntimeSkillsSnapshot,
|
|
10
|
+
} from "../agent/runtime-session";
|
|
11
|
+
import type { McpInventorySnapshot } from "../mcp/mcp-manager";
|
|
12
|
+
import type { TuiProjectionState } from "../tui/event-store";
|
|
13
|
+
import type { RemoteActivity, RemoteCursor } from "./protocol";
|
|
14
|
+
|
|
15
|
+
/** Additive v1 read model for the full terminal renderer; no raw runtime events. */
|
|
16
|
+
export type RemoteTuiSnapshot = {
|
|
17
|
+
version: 1;
|
|
18
|
+
profileName?: string;
|
|
19
|
+
modelCatalog?: ClientModelCatalog;
|
|
20
|
+
reasoningEffort?: ReasoningEffortSnapshot;
|
|
21
|
+
supportsImageInput: boolean;
|
|
22
|
+
cursor: RemoteCursor;
|
|
23
|
+
activity: RemoteActivity;
|
|
24
|
+
history: TuiProjectionState;
|
|
25
|
+
timeline: TuiTimelineLog;
|
|
26
|
+
promptScheduler: PromptSchedulerSnapshot;
|
|
27
|
+
bashGuard: BashGuardSnapshot & { interactionId?: string };
|
|
28
|
+
askUser: AskUserSnapshot & { interactionId?: string };
|
|
29
|
+
providerRetry: ProviderRetrySnapshot;
|
|
30
|
+
skills: RuntimeSkillsSnapshot;
|
|
31
|
+
mcp: McpInventorySnapshot;
|
|
32
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import type { RemoteWorkspaceConfig } from "./config";
|
|
5
|
+
import { RemoteError } from "./protocol";
|
|
6
|
+
|
|
7
|
+
/** Only resolves configured roots; selecting a child directory does not change execution scope. */
|
|
8
|
+
export async function resolveServiceWorkspace(
|
|
9
|
+
workspaces: readonly RemoteWorkspaceConfig[],
|
|
10
|
+
directory: string,
|
|
11
|
+
): Promise<RemoteWorkspaceConfig> {
|
|
12
|
+
if (!path.isAbsolute(directory))
|
|
13
|
+
throw new RemoteError(
|
|
14
|
+
400,
|
|
15
|
+
"INVALID_DIRECTORY",
|
|
16
|
+
"Workspace directory must be an absolute path on the service host.",
|
|
17
|
+
);
|
|
18
|
+
let canonical: string;
|
|
19
|
+
try {
|
|
20
|
+
canonical = await realpath(directory);
|
|
21
|
+
if (!(await stat(canonical)).isDirectory()) throw new Error("Not a directory.");
|
|
22
|
+
} catch {
|
|
23
|
+
throw new RemoteError(
|
|
24
|
+
404,
|
|
25
|
+
"DIRECTORY_NOT_FOUND",
|
|
26
|
+
"Workspace directory does not exist on the service host.",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
const match = findContainingWorkspace(workspaces, canonical);
|
|
30
|
+
if (!match)
|
|
31
|
+
throw new RemoteError(
|
|
32
|
+
404,
|
|
33
|
+
"WORKSPACE_NOT_CONFIGURED",
|
|
34
|
+
"Current directory is outside configured workspaces. Use --service-config <path> from a local terminal to register it, or select --workspace <id> explicitly.",
|
|
35
|
+
);
|
|
36
|
+
return match;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function findContainingWorkspace(
|
|
40
|
+
workspaces: readonly RemoteWorkspaceConfig[],
|
|
41
|
+
canonical: string,
|
|
42
|
+
): RemoteWorkspaceConfig | undefined {
|
|
43
|
+
const matches = workspaces
|
|
44
|
+
.filter((workspace) => {
|
|
45
|
+
const relative = path.relative(workspace.path, canonical);
|
|
46
|
+
return (
|
|
47
|
+
relative === "" ||
|
|
48
|
+
(!path.isAbsolute(relative) &&
|
|
49
|
+
relative !== ".." &&
|
|
50
|
+
!relative.startsWith(`..${path.sep}`))
|
|
51
|
+
);
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
54
|
+
return matches[0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function localWorkspaceRecord(canonical: string): RemoteWorkspaceConfig {
|
|
58
|
+
return {
|
|
59
|
+
id: `local-${createHash("sha256").update(canonical).digest("hex").slice(0, 32)}`,
|
|
60
|
+
name: path.basename(canonical) || canonical,
|
|
61
|
+
path: canonical,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function mergeWorkspaces(
|
|
66
|
+
configured: readonly RemoteWorkspaceConfig[],
|
|
67
|
+
registered: readonly RemoteWorkspaceConfig[],
|
|
68
|
+
): RemoteWorkspaceConfig[] {
|
|
69
|
+
const result = [...configured];
|
|
70
|
+
for (const workspace of registered) {
|
|
71
|
+
const match = result.find(
|
|
72
|
+
(entry) => entry.id === workspace.id || entry.path === workspace.path,
|
|
73
|
+
);
|
|
74
|
+
if (match) {
|
|
75
|
+
if (match.id !== workspace.id || match.path !== workspace.path)
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Configured workspace conflicts with a locally registered workspace identity. Preserve its ID and canonical path.",
|
|
78
|
+
);
|
|
79
|
+
} else result.push(workspace);
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
@@ -60,6 +60,22 @@ export class ResumeProjectionReader {
|
|
|
60
60
|
input.sessionId,
|
|
61
61
|
input.homeRoot,
|
|
62
62
|
);
|
|
63
|
+
return this.readDatabase({ ...input, workspaceRoot, databasePath, policy });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Read completed history while a service-owned session may have an open tail. */
|
|
67
|
+
static readDatabase(input: {
|
|
68
|
+
workspaceRoot: string;
|
|
69
|
+
sessionId: SessionId;
|
|
70
|
+
modelName: string;
|
|
71
|
+
databasePath: string;
|
|
72
|
+
policy?: TuiProjectionPolicy;
|
|
73
|
+
closedTurnsOnly?: boolean;
|
|
74
|
+
}): TuiProjectionState {
|
|
75
|
+
const { workspaceRoot, databasePath } = input;
|
|
76
|
+
const policy = validateTuiProjectionPolicy(
|
|
77
|
+
input.policy ?? defaultTuiProjectionPolicy,
|
|
78
|
+
);
|
|
63
79
|
const database = new Database(databasePath, {
|
|
64
80
|
readonly: true,
|
|
65
81
|
strict: true,
|
|
@@ -84,11 +100,12 @@ export class ResumeProjectionReader {
|
|
|
84
100
|
{ sessionId: input.sessionId },
|
|
85
101
|
);
|
|
86
102
|
}
|
|
103
|
+
const filter = input.closedTurnsOnly ? " WHERE status <> 'open'" : "";
|
|
87
104
|
const totalTurns = count(
|
|
88
|
-
database.query(
|
|
105
|
+
database.query(`SELECT COUNT(*) AS count FROM turns${filter}`).get(),
|
|
89
106
|
);
|
|
90
107
|
const turns = database
|
|
91
|
-
.query(`SELECT * FROM turns ORDER BY turn_number DESC LIMIT ?`)
|
|
108
|
+
.query(`SELECT * FROM turns${filter} ORDER BY turn_number DESC LIMIT ?`)
|
|
92
109
|
.all(policy.recentTurnLimit)
|
|
93
110
|
.reverse() as Array<Record<string, unknown>>;
|
|
94
111
|
const recentTurns = turns.map((turn) => projectTurn(database, turn, policy));
|
|
@@ -46,6 +46,8 @@ const defaultDependencies: SessionLeaseDependencies = {
|
|
|
46
46
|
|
|
47
47
|
export class SessionLease {
|
|
48
48
|
private released = false;
|
|
49
|
+
private retained = false;
|
|
50
|
+
private writerActive = true;
|
|
49
51
|
private lockPath: string;
|
|
50
52
|
|
|
51
53
|
private constructor(
|
|
@@ -167,7 +169,37 @@ export class SessionLease {
|
|
|
167
169
|
this.lockPath = path.join(sessionDirectory, "active.lock");
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
/** Keep the canonical process lease while its current SQLite writer is unloaded. */
|
|
173
|
+
retainOwnership(): this {
|
|
174
|
+
this.requireActive();
|
|
175
|
+
this.retained = true;
|
|
176
|
+
return this;
|
|
177
|
+
}
|
|
178
|
+
claim(sessionDirectory: string, sessionId: SessionId): this {
|
|
179
|
+
this.requireActive();
|
|
180
|
+
if (
|
|
181
|
+
!this.retained ||
|
|
182
|
+
this.writerActive ||
|
|
183
|
+
sessionId !== this.record.sessionId ||
|
|
184
|
+
path.join(sessionDirectory, "active.lock") !== this.lockPath
|
|
185
|
+
)
|
|
186
|
+
throw new Error("Retained session lease is not available for this writer.");
|
|
187
|
+
this.writerActive = true;
|
|
188
|
+
return this;
|
|
189
|
+
}
|
|
190
|
+
async releaseOwnership(): Promise<void> {
|
|
191
|
+
if (this.released) return;
|
|
192
|
+
if (this.retained && this.writerActive)
|
|
193
|
+
throw new Error("Close the session writer before releasing service ownership.");
|
|
194
|
+
this.retained = false;
|
|
195
|
+
await this.release();
|
|
196
|
+
}
|
|
197
|
+
|
|
170
198
|
async release(): Promise<void> {
|
|
199
|
+
if (this.retained && !this.released) {
|
|
200
|
+
this.writerActive = false;
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
171
203
|
if (this.released) {
|
|
172
204
|
return;
|
|
173
205
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionLease } from "./session-lock";
|
|
1
2
|
import type {
|
|
2
3
|
ContextRevisionId,
|
|
3
4
|
IterationId,
|
|
@@ -269,6 +270,7 @@ export type CreateNewSessionStoreInput = {
|
|
|
269
270
|
};
|
|
270
271
|
|
|
271
272
|
export type OpenSessionStoreInput = {
|
|
273
|
+
lease?: SessionLease;
|
|
272
274
|
workspaceRoot: string;
|
|
273
275
|
sessionId: SessionId;
|
|
274
276
|
clock?: () => string;
|
|
@@ -374,10 +374,9 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
374
374
|
await validateSecureOptionalFile(optionalFile, input.sessionId);
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
const lease =
|
|
378
|
-
sessionDirectory,
|
|
379
|
-
sessionId: input.sessionId
|
|
380
|
-
});
|
|
377
|
+
const lease = input.lease
|
|
378
|
+
? input.lease.claim(sessionDirectory, input.sessionId)
|
|
379
|
+
: await SessionLease.acquire({ sessionDirectory, sessionId: input.sessionId });
|
|
381
380
|
let database: Database | undefined;
|
|
382
381
|
try {
|
|
383
382
|
database = openWritableDatabase(databasePath);
|
|
@@ -1317,6 +1316,10 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
1317
1316
|
}
|
|
1318
1317
|
}
|
|
1319
1318
|
|
|
1319
|
+
retainLease(): SessionLease {
|
|
1320
|
+
return this.lease.retainOwnership();
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1320
1323
|
async close(reason: SessionCloseReason): Promise<void> {
|
|
1321
1324
|
if (this.closed) {
|
|
1322
1325
|
return;
|
package/src/tui/app.tsx
CHANGED
|
@@ -20,7 +20,6 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
|
|
|
20
20
|
import { ModelRequestMediaAggregateError } from "../model/model-client";
|
|
21
21
|
import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
|
|
22
22
|
import type { SessionId } from "../ids/runtime-id";
|
|
23
|
-
import { readLastAssistantResponse } from "../session/session-last-response-reader";
|
|
24
23
|
import type { PromptHistory } from "./prompt-history";
|
|
25
24
|
import { Footer } from "./components/footer";
|
|
26
25
|
import { AssistantMarkdownProvider } from "./components/assistant-markdown";
|
|
@@ -56,8 +55,8 @@ import {
|
|
|
56
55
|
type ProjectSlashCommand,
|
|
57
56
|
} from "./project-slash-commands";
|
|
58
57
|
import type { TuiSessionController } from "./tui-session-controller";
|
|
59
|
-
import type { SessionSummary } from "../
|
|
60
|
-
import type {
|
|
58
|
+
import type { ClientSessionSummary as SessionSummary } from "../client/session-client";
|
|
59
|
+
import type { ClientModelProfile, ClientModelProfiles } from "../client/model-catalog";
|
|
61
60
|
import { loadViewFile, type ViewFile } from "./view-file";
|
|
62
61
|
import { writeClipboardText } from "./clipboard";
|
|
63
62
|
import type { WorkspaceFileLister } from "./workspace-file-search";
|
|
@@ -74,7 +73,7 @@ export type AppProps = {
|
|
|
74
73
|
history?: PromptHistory;
|
|
75
74
|
projectSlashCommands?: readonly ProjectSlashCommand[];
|
|
76
75
|
fileLister?: WorkspaceFileLister;
|
|
77
|
-
profiles?:
|
|
76
|
+
profiles?: ClientModelProfiles;
|
|
78
77
|
persistDefaultProfile?: (profileName: string) => Promise<void>;
|
|
79
78
|
readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
|
|
80
79
|
readLastResponse?: (
|
|
@@ -203,16 +202,34 @@ export function App(props: AppProps) {
|
|
|
203
202
|
write(clearTerminal);
|
|
204
203
|
setStaticRenderEpoch((current) => current + 1);
|
|
205
204
|
}, [write]);
|
|
205
|
+
const presentationRevision = binding.projectionStore.getPresentationRevision?.();
|
|
206
|
+
const renderedPresentation = useRef({
|
|
207
|
+
sessionId: binding.sessionId,
|
|
208
|
+
revision: presentationRevision,
|
|
209
|
+
});
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
const previous = renderedPresentation.current;
|
|
212
|
+
renderedPresentation.current = {
|
|
213
|
+
sessionId: binding.sessionId,
|
|
214
|
+
revision: presentationRevision,
|
|
215
|
+
};
|
|
216
|
+
if (
|
|
217
|
+
previous.sessionId === binding.sessionId &&
|
|
218
|
+
previous.revision !== presentationRevision
|
|
219
|
+
)
|
|
220
|
+
restoreStaticViewport();
|
|
221
|
+
}, [binding.sessionId, presentationRevision, restoreStaticViewport]);
|
|
206
222
|
const staticItems = useMemo<Array<typeof STATIC_HEADER | TuiCommittedItem>>(
|
|
207
223
|
() => [STATIC_HEADER, ...log.committed],
|
|
208
224
|
[log.committed],
|
|
209
225
|
);
|
|
210
226
|
|
|
227
|
+
const profiles = props.profiles ?? binding.modelProfiles?.();
|
|
211
228
|
const canSwitchModel =
|
|
212
229
|
state.recentTurns.length === 0 &&
|
|
213
230
|
state.activeTurn === undefined &&
|
|
214
|
-
|
|
215
|
-
|
|
231
|
+
profiles !== undefined &&
|
|
232
|
+
profiles.profiles.size > 1;
|
|
216
233
|
const reasoningEffort = binding.reasoningEffort?.();
|
|
217
234
|
const hasReasoningEffort = reasoningEffort !== undefined;
|
|
218
235
|
const activeResumePicker =
|
|
@@ -225,8 +242,8 @@ export function App(props: AppProps) {
|
|
|
225
242
|
);
|
|
226
243
|
const availableCommands = [...builtInCommands, ...(props.projectSlashCommands ?? [])];
|
|
227
244
|
|
|
228
|
-
const profileList =
|
|
229
|
-
const cycleReasoningEffort = () => {
|
|
245
|
+
const profileList = profiles ? [...profiles.profiles.values()] : [];
|
|
246
|
+
const cycleReasoningEffort = async () => {
|
|
230
247
|
const current = binding.reasoningEffort?.();
|
|
231
248
|
if (current === undefined) {
|
|
232
249
|
return;
|
|
@@ -239,7 +256,7 @@ export function App(props: AppProps) {
|
|
|
239
256
|
return;
|
|
240
257
|
}
|
|
241
258
|
try {
|
|
242
|
-
const updated = binding.setReasoningEffort?.(nextEffort);
|
|
259
|
+
const updated = await binding.setReasoningEffort?.(nextEffort);
|
|
243
260
|
if (updated === undefined) {
|
|
244
261
|
throw new Error("Reasoning effort control is unavailable.");
|
|
245
262
|
}
|
|
@@ -282,6 +299,24 @@ export function App(props: AppProps) {
|
|
|
282
299
|
return;
|
|
283
300
|
}
|
|
284
301
|
|
|
302
|
+
if (binding.stopTurn !== undefined) {
|
|
303
|
+
if (isCancelling) return;
|
|
304
|
+
setIsCancelling(true);
|
|
305
|
+
setNotice("Cancelling current turn...");
|
|
306
|
+
void binding.stopTurn().then(
|
|
307
|
+
() => {
|
|
308
|
+
setIsCancelling(false);
|
|
309
|
+
setNotice((current) =>
|
|
310
|
+
current === "Cancelling current turn..." ? undefined : current,
|
|
311
|
+
);
|
|
312
|
+
},
|
|
313
|
+
(error: unknown) => {
|
|
314
|
+
setIsCancelling(false);
|
|
315
|
+
setNotice(`Stop failed: ${errorMessage(error)}`);
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
285
320
|
const controller = activeController.current;
|
|
286
321
|
if (controller === undefined || controller.signal.aborted) {
|
|
287
322
|
return;
|
|
@@ -384,7 +419,7 @@ export function App(props: AppProps) {
|
|
|
384
419
|
restoreStaticViewport();
|
|
385
420
|
};
|
|
386
421
|
|
|
387
|
-
const doSwitchModel = (profile:
|
|
422
|
+
const doSwitchModel = (profile: ClientModelProfile) => {
|
|
388
423
|
setShowModelPicker(false);
|
|
389
424
|
setModelPickerState(undefined);
|
|
390
425
|
setNotice(undefined);
|
|
@@ -394,7 +429,9 @@ export function App(props: AppProps) {
|
|
|
394
429
|
.then(async () => {
|
|
395
430
|
setGitBranchRefresh((current) => current + 1);
|
|
396
431
|
try {
|
|
397
|
-
await
|
|
432
|
+
await (
|
|
433
|
+
props.persistDefaultProfile ?? props.sessionController.persistDefaultProfile
|
|
434
|
+
)?.(profile.name);
|
|
398
435
|
setNotice(`Switched to model profile "${profile.name}" (${profile.model}).`);
|
|
399
436
|
} catch (error) {
|
|
400
437
|
setNotice(
|
|
@@ -462,10 +499,7 @@ export function App(props: AppProps) {
|
|
|
462
499
|
void Promise.resolve()
|
|
463
500
|
.then(() =>
|
|
464
501
|
props.readLastResponse === undefined
|
|
465
|
-
?
|
|
466
|
-
workspaceRoot,
|
|
467
|
-
sessionId: binding.sessionId,
|
|
468
|
-
})
|
|
502
|
+
? binding.readLastResponse()
|
|
469
503
|
: props.readLastResponse(workspaceRoot, binding.sessionId),
|
|
470
504
|
)
|
|
471
505
|
.then(async (markdown) => {
|
|
@@ -496,7 +530,7 @@ export function App(props: AppProps) {
|
|
|
496
530
|
return false;
|
|
497
531
|
}
|
|
498
532
|
try {
|
|
499
|
-
const queued = binding.queueFollowUp?.(submission.userMessage);
|
|
533
|
+
const queued = await binding.queueFollowUp?.(submission.userMessage);
|
|
500
534
|
if (queued === undefined) {
|
|
501
535
|
throw new Error("TUI session binding does not support follow-up queuing.");
|
|
502
536
|
}
|
|
@@ -605,10 +639,10 @@ export function App(props: AppProps) {
|
|
|
605
639
|
}
|
|
606
640
|
};
|
|
607
641
|
|
|
608
|
-
const onSubmit = (
|
|
642
|
+
const onSubmit = async (
|
|
609
643
|
submission: PromptSubmission,
|
|
610
644
|
signal: AbortSignal,
|
|
611
|
-
):
|
|
645
|
+
): Promise<PromptSubmissionOutcome> => {
|
|
612
646
|
const { userMessage } = submission;
|
|
613
647
|
const trimmed = userMessage.content.trim();
|
|
614
648
|
const shouldRestoreViewport = showStatus || showSkills || showMcp;
|
|
@@ -664,7 +698,7 @@ export function App(props: AppProps) {
|
|
|
664
698
|
return true;
|
|
665
699
|
}
|
|
666
700
|
if (command.type === "yolo") {
|
|
667
|
-
binding.setYoloMode(command.enabled);
|
|
701
|
+
await binding.setYoloMode(command.enabled);
|
|
668
702
|
setNotice(
|
|
669
703
|
command.enabled
|
|
670
704
|
? "YOLO enabled for this session; dangerous Bash commands will run without confirmation."
|
|
@@ -770,7 +804,7 @@ export function App(props: AppProps) {
|
|
|
770
804
|
if (command.type === "reasoning_status") {
|
|
771
805
|
setNotice(formatReasoningEffortStatus(current));
|
|
772
806
|
} else if (command.type === "reasoning_reset") {
|
|
773
|
-
const reset = binding.resetReasoningEffort?.();
|
|
807
|
+
const reset = await binding.resetReasoningEffort?.();
|
|
774
808
|
if (reset === undefined) {
|
|
775
809
|
throw new Error("Reasoning effort control is unavailable.");
|
|
776
810
|
}
|
|
@@ -778,7 +812,7 @@ export function App(props: AppProps) {
|
|
|
778
812
|
`Reasoning effort reset to profile default ${JSON.stringify(reset.defaultEffort)}.`,
|
|
779
813
|
);
|
|
780
814
|
} else {
|
|
781
|
-
const updated = binding.setReasoningEffort?.(command.effort);
|
|
815
|
+
const updated = await binding.setReasoningEffort?.(command.effort);
|
|
782
816
|
if (updated === undefined) {
|
|
783
817
|
throw new Error("Reasoning effort control is unavailable.");
|
|
784
818
|
}
|
|
@@ -800,7 +834,7 @@ export function App(props: AppProps) {
|
|
|
800
834
|
return false;
|
|
801
835
|
}
|
|
802
836
|
if (command.type === "model_switch") {
|
|
803
|
-
const targetProfile =
|
|
837
|
+
const targetProfile = profiles?.profiles.get(command.profileName);
|
|
804
838
|
if (targetProfile === undefined) {
|
|
805
839
|
setNotice(`Unknown model profile: ${command.profileName}`);
|
|
806
840
|
return false;
|
|
@@ -969,18 +1003,22 @@ export function App(props: AppProps) {
|
|
|
969
1003
|
/>
|
|
970
1004
|
) : askUser.pending !== undefined ? (
|
|
971
1005
|
<AskUser
|
|
1006
|
+
key={askUser.interactionId}
|
|
972
1007
|
question={askUser.pending.question}
|
|
973
1008
|
options={askUser.pending.options}
|
|
974
1009
|
onSelect={(selectedIndex) => {
|
|
975
1010
|
void binding
|
|
976
|
-
.resolveAskUser(
|
|
1011
|
+
.resolveAskUser(
|
|
1012
|
+
{ outcome: "selected", selectedIndex },
|
|
1013
|
+
askUser.interactionId,
|
|
1014
|
+
)
|
|
977
1015
|
.catch((error: unknown) =>
|
|
978
1016
|
setNotice(`Answer failed: ${errorMessage(error)}`),
|
|
979
1017
|
);
|
|
980
1018
|
}}
|
|
981
1019
|
onDismiss={() => {
|
|
982
1020
|
void binding
|
|
983
|
-
.resolveAskUser({ outcome: "dismissed" })
|
|
1021
|
+
.resolveAskUser({ outcome: "dismissed" }, askUser.interactionId)
|
|
984
1022
|
.catch((error: unknown) =>
|
|
985
1023
|
setNotice(`Dismiss failed: ${errorMessage(error)}`),
|
|
986
1024
|
);
|
|
@@ -992,7 +1030,7 @@ export function App(props: AppProps) {
|
|
|
992
1030
|
reason={bashGuard.pending.reason}
|
|
993
1031
|
onDecision={(decision) => {
|
|
994
1032
|
void binding
|
|
995
|
-
.resolveBashConfirmation(decision)
|
|
1033
|
+
.resolveBashConfirmation(decision, bashGuard.interactionId)
|
|
996
1034
|
.catch((error: unknown) =>
|
|
997
1035
|
setNotice(`Bash confirmation failed: ${errorMessage(error)}`),
|
|
998
1036
|
);
|
|
@@ -1014,6 +1052,7 @@ export function App(props: AppProps) {
|
|
|
1014
1052
|
version={props.version}
|
|
1015
1053
|
workspaceRoot={binding.workspaceRoot}
|
|
1016
1054
|
gitBranch={gitBranch}
|
|
1055
|
+
serviceStatus={binding.projectionStore.getServiceStatus?.()}
|
|
1017
1056
|
contextUsage={state.contextUsage}
|
|
1018
1057
|
isDisabled={
|
|
1019
1058
|
isSessionOperation ||
|
|
@@ -1034,7 +1073,9 @@ export function App(props: AppProps) {
|
|
|
1034
1073
|
verifyImageAssets={binding.verifyImageAssets}
|
|
1035
1074
|
onCycleReasoningEffort={
|
|
1036
1075
|
hasReasoningEffort && promptScheduler.state !== "running"
|
|
1037
|
-
?
|
|
1076
|
+
? () => {
|
|
1077
|
+
void cycleReasoningEffort();
|
|
1078
|
+
}
|
|
1038
1079
|
: undefined
|
|
1039
1080
|
}
|
|
1040
1081
|
onSubmit={onSubmit}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Box, Text, useInput } from "ink";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
-
import type {
|
|
3
|
+
import type { ClientModelProfile } from "../../client/model-catalog";
|
|
4
4
|
|
|
5
5
|
export type ModelPickerProps = {
|
|
6
|
-
profiles: readonly
|
|
6
|
+
profiles: readonly ClientModelProfile[];
|
|
7
7
|
currentProfileName?: string;
|
|
8
8
|
isSwitching?: boolean;
|
|
9
9
|
error?: string;
|
|
10
10
|
onCancel: () => void;
|
|
11
|
-
onSelect: (profile:
|
|
11
|
+
onSelect: (profile: ClientModelProfile) => void;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
14
|
export function ModelPicker(props: ModelPickerProps) {
|
|
@@ -89,7 +89,7 @@ function ModelPickerContent(props: ModelPickerProps) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function ProfileOption(props: {
|
|
92
|
-
profile:
|
|
92
|
+
profile: ClientModelProfile;
|
|
93
93
|
isSelected: boolean;
|
|
94
94
|
isCurrent: boolean;
|
|
95
95
|
}) {
|
|
@@ -117,7 +117,7 @@ function ProfileOption(props: {
|
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
function initialSelectedIndex(
|
|
120
|
-
profiles: readonly
|
|
120
|
+
profiles: readonly ClientModelProfile[],
|
|
121
121
|
currentProfileName?: string,
|
|
122
122
|
): number {
|
|
123
123
|
if (currentProfileName === undefined) {
|