indusagi-coding-agent 0.2.13 → 0.2.14
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 +19 -0
- package/dist/entry.js +1762 -958
- package/dist/guardrails.js +145 -35
- package/dist/index.js +1810 -1006
- package/dist/review-agent.js +344 -60
- package/dist/review-server.js +346 -62
- package/package.json +12 -3
- package/src/boot/auth-vault.ts +4 -5
- package/src/boot/runners/addon-wiring.ts +111 -3
- package/src/boot/runners/benchmark-integrity.ts +107 -0
- package/src/boot/runners/repl-runner.ts +2 -1
- package/src/boot/runners/session.ts +18 -8
- package/src/boot/server-token.ts +20 -11
- package/src/briefing/context-docs.ts +144 -12
- package/src/channels/oneshot.ts +6 -0
- package/src/conductor/conductor.ts +67 -12
- package/src/conductor/contract.ts +4 -3
- package/src/conductor/transcript-store/store.ts +4 -8
- package/src/console/auth-status.ts +9 -4
- package/src/console/components/AgentsView.ts +2 -2
- package/src/console/components/BackgroundAgents.ts +15 -2
- package/src/console/components/Banner.ts +20 -9
- package/src/console/components/Composer.ts +57 -82
- package/src/console/components/ScrollViewport.ts +46 -0
- package/src/console/components/TerminalConsole.ts +165 -162
- package/src/console/components/WorkingIndicator.ts +6 -5
- package/src/console/components/banner-sweep.ts +5 -1
- package/src/console/components/welcome.ts +3 -3
- package/src/console/contract.ts +10 -5
- package/src/console/index.ts +7 -7
- package/src/console/input/complete.ts +2 -2
- package/src/console/input/dir-reader.ts +3 -1
- package/src/console/input/graphemes.ts +22 -0
- package/src/console/input/keymap.ts +6 -4
- package/src/console/mount.ts +45 -3
- package/src/console/overlays/auth.ts +28 -8
- package/src/console/overlays/boards.ts +13 -40
- package/src/console/overlays/host.ts +10 -0
- package/src/console/overlays/pickers.ts +35 -30
- package/src/console/reducer.ts +12 -5
- package/src/console/slash/builtins.ts +4 -3
- package/src/console/slash/commands/integrations.ts +13 -14
- package/src/console/slash/commands/transcript.ts +3 -8
- package/src/console/slash/commands/workbench.ts +2 -2
- package/src/console/test-support.ts +48 -0
- package/src/integrations/sarvam/config.ts +15 -13
- package/src/kit/external-editor.ts +17 -1
- package/src/kit/run-shell.ts +31 -0
- package/src/launch/invocation/flags.ts +12 -0
- package/src/launch/login.ts +23 -10
- package/src/launch/oauth.ts +3 -0
- package/src/models.ts +78 -2
- package/src/security/private-state.ts +122 -0
- package/src/window-budget/summarize/condense.ts +4 -1
- package/src/workspace/brand.ts +1 -1
- package/src/workspace/locator.ts +2 -2
|
@@ -3,6 +3,7 @@ import type { AgentEvent, AgentMessage, AgentTool, CanUseToolFn, ThinkingLevel }
|
|
|
3
3
|
import { createZeroUsage } from "indusagi/ai";
|
|
4
4
|
import type { Model, Usage } from "indusagi/ai";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { runShell } from "../kit/run-shell.js";
|
|
6
7
|
import { conductorFault } from "./contract.js";
|
|
7
8
|
import type { ApprovalResolver, CodingAgentThinkingLevel, ConductorFault, ConductorPhase, ConductorState, PermissionMode, SessionConductor, SessionConductorOptions, SessionHead } from "./contract.js";
|
|
8
9
|
import { ModelCatalog, ModelMatcher } from "./catalog/index.js";
|
|
@@ -28,7 +29,7 @@ export interface AgentLike {
|
|
|
28
29
|
setTools(tools: AgentTool[]): void;
|
|
29
30
|
setThinkingLevel?(level: ThinkingLevel): void;
|
|
30
31
|
}
|
|
31
|
-
export interface CondenseOpts { readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
|
|
32
|
+
export interface CondenseOpts { readonly instructions?: string; readonly force?: boolean; readonly model?: Model<any>; readonly contextTokens?: number; readonly signal?: AbortSignal; readonly apiKey?: string }
|
|
32
33
|
export type CondenseFn = (messages: AgentMessage[], opts?: CondenseOpts) => AgentMessage[] | Promise<AgentMessage[]>;
|
|
33
34
|
export const noopCondense: CondenseFn = (messages) => messages;
|
|
34
35
|
export interface RetryPolicy { readonly maxAttempts: number; readonly baseDelayMs: number }
|
|
@@ -134,6 +135,8 @@ class SessionConductorImpl implements SessionConductor {
|
|
|
134
135
|
#matcher?: ModelMatcher;
|
|
135
136
|
#state: ConductorState;
|
|
136
137
|
#busy = false;
|
|
138
|
+
#activeWork?: Promise<unknown>;
|
|
139
|
+
#shellAbort?: AbortController;
|
|
137
140
|
#draining = false;
|
|
138
141
|
#queue: { mode: "steer" | "followUp"; text: string }[] = [];
|
|
139
142
|
#boundModel?: Model<any>;
|
|
@@ -164,12 +167,22 @@ class SessionConductorImpl implements SessionConductor {
|
|
|
164
167
|
clearQueue() { this.#queue.length = 0; this.#hub.emit({ kind: "queue", count: 0 }); }
|
|
165
168
|
dequeueLast() { const item = this.#queue.pop(); if (item) this.#hub.emit({ kind: "queue", count: this.#queue.length }); return item?.text; }
|
|
166
169
|
steerNow() { const item = this.#queue.pop(); if (!item) return false; this.#queue.unshift({ mode: "steer", text: item.text }); this.agent.abort(); return true; }
|
|
167
|
-
abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
|
|
170
|
+
abort() { this.clearQueue(); this.#condenseAbort?.abort(); this.#shellAbort?.abort(); this.agent.abort(); const fault = conductorFault("aborted", "turn aborted by caller"); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); this.#hub.emit({ kind: "idle" }); }
|
|
168
171
|
|
|
169
172
|
async submit(input: string) {
|
|
170
173
|
if (this.#busy) { this.enqueue(input); return this.#state; }
|
|
171
174
|
this.#busy = true;
|
|
172
|
-
|
|
175
|
+
const work = (async () => {
|
|
176
|
+
try { await this.#runTurn(input); await this.#drainQueue(); return this.#state; }
|
|
177
|
+
finally { this.#busy = false; }
|
|
178
|
+
})();
|
|
179
|
+
this.#activeWork = work;
|
|
180
|
+
return work;
|
|
181
|
+
}
|
|
182
|
+
async #stopWork() {
|
|
183
|
+
if (!this.#busy) return;
|
|
184
|
+
this.abort();
|
|
185
|
+
await this.#activeWork;
|
|
173
186
|
}
|
|
174
187
|
async #drainQueue() { if (this.#draining) return; this.#draining = true; try { while (this.#queue.length) { const index = this.#queue.findIndex((item) => item.mode === "steer"); const item = index >= 0 ? this.#queue.splice(index, 1)[0] : this.#queue.shift()!; this.#hub.emit({ kind: "queue", count: this.#queue.length }); await this.#runTurn(item.text); } } finally { this.#draining = false; } }
|
|
175
188
|
async #runTurn(input: string) {
|
|
@@ -201,24 +214,62 @@ class SessionConductorImpl implements SessionConductor {
|
|
|
201
214
|
}
|
|
202
215
|
|
|
203
216
|
selectModel(id: string) { const card = this.#matcher?.resolveCard(id); if (!card) { const fault = conductorFault("model", `no model matches "${id}"`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.#applyModel(card); }
|
|
204
|
-
cycleModel(id: string
|
|
217
|
+
cycleModel(id: string, direction: -1 | 1 = 1) {
|
|
218
|
+
if (id.trim()) { this.selectModel(id); return; }
|
|
219
|
+
const cards = this.availableModels();
|
|
220
|
+
if (!cards.length) return;
|
|
221
|
+
const at = cards.findIndex(card => card.id === this.#state.modelId);
|
|
222
|
+
const index = at < 0 ? 0 : (at + direction + cards.length) % cards.length;
|
|
223
|
+
this.selectModel(cards[index].id);
|
|
224
|
+
}
|
|
205
225
|
#applyModel(card: CatalogCard) { const model = withGatewayBaseUrl(card.model, this.#gatewayBaseUrls); this.agent.setModel(model); this.#boundModel = model; this.#dispatch({ type: "model", modelId: card.id }); }
|
|
206
226
|
updateGatewayBaseUrls(map: Record<string, string>) { this.#gatewayBaseUrls = map; this.selectModel(this.#state.modelId); }
|
|
207
227
|
registerTools(tools: AgentTool[]) { this.agent.setTools([...this.#deckTools, ...tools]); }
|
|
208
228
|
|
|
209
|
-
async condense(
|
|
229
|
+
async condense(instructions?: string) {
|
|
230
|
+
if (this.#busy) return "busy" as const;
|
|
231
|
+
this.#busy = true; this.#condenseAbort = new AbortController();
|
|
232
|
+
const work = (async () => {
|
|
233
|
+
try { const result = await this.#runCondense(true, this.#condenseAbort!.signal, instructions); await this.#drainQueue(); return result; }
|
|
234
|
+
finally { this.#busy = false; this.#condenseAbort = undefined; }
|
|
235
|
+
})();
|
|
236
|
+
this.#activeWork = work;
|
|
237
|
+
return work;
|
|
238
|
+
}
|
|
210
239
|
cancelCondense() { this.#condenseAbort?.abort(); }
|
|
211
|
-
async #runCondense(force: boolean, signal?: AbortSignal) {
|
|
240
|
+
async #runCondense(force: boolean, signal?: AbortSignal, instructions?: string) {
|
|
212
241
|
const before = [...this.agent.state.messages]; this.#dispatch({ type: "phase", phase: "condensing" });
|
|
213
|
-
try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal }); if (signal?.aborted) return "cancelled" as const; if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
|
|
242
|
+
try { const after = await (this.deps.condense ?? noopCondense)(before, { force, model: this.#boundModel, contextTokens: this.#state.contextTokens, signal, instructions }); if (signal?.aborted) { this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"}); return "cancelled" as const; } if (after.length >= before.length) { this.#dispatch({ type: "settled" }); return "nothing" as const; } this.agent.replaceMessages(after); await this.store.reset(); for (let i = 0; i < after.length; i++) await this.store.append(after[i], i === 0 ? "condense" : undefined); this.#dispatch({ type: "head", head: this.store.head }); this.#hub.emit({ kind: "compacted", manual: force }); this.#dispatch({ type: "settled" }); return "condensed" as const; } catch (error) { const fault = conductorFault("overflow", "auto-condense failed", error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return "failed" as const; }
|
|
214
243
|
}
|
|
215
244
|
|
|
216
245
|
async fork(id: string) { await this.#move(id, `cannot fork from "${id}"`); }
|
|
217
246
|
async navigateTree(id: string) { await this.#move(id, `cannot navigate to "${id}"`); }
|
|
218
|
-
async #move(id: string, message: string) { try { await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
|
|
247
|
+
async #move(id: string, message: string) { try { await this.#stopWork(); await this.store.branchAt(id); this.agent.replaceMessages(this.store.messages()); this.#dispatch({ type: "head", head: this.store.head }); this.#dispatch({ type: "settled" }); this.#hub.emit({ kind: "reset" }); this.#hub.emit({ kind: "idle" }); } catch (error) { const fault = conductorFault("persistence", message, error); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); } }
|
|
219
248
|
async restoreCode(id: string) { return this.options.checkpoint?.restore(id) ?? []; }
|
|
220
249
|
async codeRestoreInfo(id: string) { return { canRestore: this.options.checkpoint?.hasSnapshot(id) ?? false }; }
|
|
221
|
-
async executeBash(command: string, opts: { excludeFromContext?: boolean
|
|
250
|
+
async executeBash(command: string, opts: { excludeFromContext?: boolean; signal?: AbortSignal } = {}) {
|
|
251
|
+
if (this.#busy) throw new Error("A turn is running — stop it before running a shell command.");
|
|
252
|
+
this.#busy = true; this.#shellAbort = new AbortController();
|
|
253
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, this.#shellAbort.signal]) : this.#shellAbort.signal;
|
|
254
|
+
const work = (async () => {
|
|
255
|
+
try {
|
|
256
|
+
this.#dispatch({type:"phase",phase:"tooling"});
|
|
257
|
+
const outcome = await runShell(command, this.#workspace, signal);
|
|
258
|
+
if (!opts.excludeFromContext) {
|
|
259
|
+
const message: AgentMessage = {role:"user",content:`$ ${command}\n${outcome.output}`.trimEnd(),timestamp:Date.now()};
|
|
260
|
+
const entry = await this.store.append(message, "note");
|
|
261
|
+
this.agent.replaceMessages([...this.agent.state.messages, message]);
|
|
262
|
+
this.#dispatch({type:"head",head:this.store.head});
|
|
263
|
+
this.#hub.emit({kind:"persisted",entryId:entry.id});
|
|
264
|
+
}
|
|
265
|
+
this.#dispatch({type:"settled"}); this.#hub.emit({kind:"idle"});
|
|
266
|
+
await this.#drainQueue();
|
|
267
|
+
return outcome;
|
|
268
|
+
} finally { this.#busy = false; this.#shellAbort = undefined; }
|
|
269
|
+
})();
|
|
270
|
+
this.#activeWork = work;
|
|
271
|
+
return work;
|
|
272
|
+
}
|
|
222
273
|
|
|
223
274
|
stats() { const messages = this.agent.state.messages; let userMessages = 0, assistantMessages = 0, toolCalls = 0, toolResults = 0; for (const message of messages) { if (message.role === "user") userMessages++; else if (message.role === "assistant") { assistantMessages++; for (const part of message.content ?? []) if (typeof part === "object" && part && part.type === "toolCall") toolCalls++; } else if (message.role === "toolResult") toolResults++; } const usage = this.#state.usage; return { sessionId: this.#state.head.sessionId, userMessages, assistantMessages, toolCalls, toolResults, totalMessages: messages.length, tokens: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, total: usage.totalTokens }, cost: usage.cost.total }; }
|
|
224
275
|
costReport() { const stats = this.stats(); return [`Total cost: $${stats.cost.toFixed(4)}`, `Total tokens: ${stats.tokens.total}`, ` Input: ${stats.tokens.input}`, ` Output: ${stats.tokens.output}`, ` Cache read: ${stats.tokens.cacheRead}`, ` Cache write: ${stats.tokens.cacheWrite}`].join("\n"); }
|
|
@@ -233,7 +284,11 @@ class SessionConductorImpl implements SessionConductor {
|
|
|
233
284
|
cyclePermissionMode() { const current = this.modeHolder.mode === "bypassPermissions" ? "bypass" : this.modeHolder.mode; const next = PERMISSION_CYCLE[(PERMISSION_CYCLE.indexOf(current) + 1) % PERMISSION_CYCLE.length]; this.setPermissionMode(next); return next; }
|
|
234
285
|
sessionName() { return this.#sessionName; }
|
|
235
286
|
setSessionName(name: string) { this.#sessionName = name; }
|
|
236
|
-
async resume(id: string) { if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({ kind: "idle" }); }
|
|
237
|
-
async newSession() { this
|
|
238
|
-
#dispatch(action: StateAction) {
|
|
287
|
+
async resume(id: string) { await this.#stopWork(); if (!await this.store.load(id)) { const fault = conductorFault("persistence", `no session "${id}" to resume`); this.#dispatch({ type: "fault", fault }); this.#hub.emit({ kind: "fault", fault }); return; } this.agent.replaceMessages(this.store.messages()); this.#state = { ...this.#state, head: this.store.head, usage: this.store.usage ?? createZeroUsage(), phase: "idle" }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
|
|
288
|
+
async newSession() { await this.#stopWork(); this.agent.replaceMessages([]); this.store.startNewSession(newSessionId()); this.#queue.length = 0; this.#sessionName = undefined; this.#state = { phase: "idle", head: this.store.head, usage: createZeroUsage(), contextTokens: 0, modelId: this.#state.modelId }; this.#hub.emit({kind:"reset"}); this.#hub.emit({ kind: "idle" }); }
|
|
289
|
+
#dispatch(action: StateAction) {
|
|
290
|
+
const phase = this.#state.phase;
|
|
291
|
+
this.#state = reduceState(this.#state, action);
|
|
292
|
+
if (phase !== this.#state.phase) this.#hub.emit({kind:"phase",phase:this.#state.phase});
|
|
293
|
+
}
|
|
239
294
|
}
|
|
@@ -119,7 +119,7 @@ export function conductorFault(kind: FaultKind, message: string, cause?: unknown
|
|
|
119
119
|
* - `queue` — the pending-input queue changed; `count` is its new depth.
|
|
120
120
|
* - `idle` — the conductor has no in-flight work and is ready for input.
|
|
121
121
|
*/
|
|
122
|
-
export type SessionSignal = {
|
|
122
|
+
export type SessionSignal = { readonly kind: "reset" } | { readonly kind: "phase"; readonly phase: ConductorPhase } | {
|
|
123
123
|
readonly kind: "prompt";
|
|
124
124
|
readonly text: string;
|
|
125
125
|
} | {
|
|
@@ -370,6 +370,7 @@ export interface SessionStats {
|
|
|
370
370
|
}
|
|
371
371
|
/** Options for {@link SessionConductor.executeBash}. */
|
|
372
372
|
export interface ExecuteBashOptions {
|
|
373
|
+
readonly signal?: AbortSignal;
|
|
373
374
|
/**
|
|
374
375
|
* When `true`, the command's output is *not* recorded as a transcript note,
|
|
375
376
|
* so it never re-enters the agent's context. Defaults to `false`.
|
|
@@ -654,7 +655,7 @@ export interface SessionConductor {
|
|
|
654
655
|
* While the condense runs, {@link submit} queues instead of racing it, and the
|
|
655
656
|
* queue drains once the condense settles.
|
|
656
657
|
*/
|
|
657
|
-
condense(): Promise<CondenseOutcome>;
|
|
658
|
+
condense(instructions?: string): Promise<CondenseOutcome>;
|
|
658
659
|
/**
|
|
659
660
|
* Cancel an in-flight manual {@link condense} (the `/compact` Esc affordance).
|
|
660
661
|
* The summarizer's result is discarded and the transcript stays untouched; a
|
|
@@ -842,5 +843,5 @@ export interface SessionConductor {
|
|
|
842
843
|
*
|
|
843
844
|
* @param id canonical id of the model to switch to
|
|
844
845
|
*/
|
|
845
|
-
cycleModel?(id: string): void;
|
|
846
|
+
cycleModel?(id: string, direction?: -1 | 1): void;
|
|
846
847
|
}
|
|
@@ -4,6 +4,7 @@ import type { Usage } from "indusagi/ai";
|
|
|
4
4
|
import { TRANSCRIPT_SCHEMA } from "../contract.js";
|
|
5
5
|
import type { SessionHead, TranscriptEntry, TranscriptRole } from "../contract.js";
|
|
6
6
|
import { encodeEntry, encodeHead, parseSessionText, roleForMessage } from "./serialize.js";
|
|
7
|
+
import { appendPrivateFileSync, atomicWritePrivateFileSync, readPrivateFileSync } from "../../security/private-state.js";
|
|
7
8
|
|
|
8
9
|
export interface TranscriptBackend {
|
|
9
10
|
locate(sessionId: string): string;
|
|
@@ -54,18 +55,13 @@ export function fsBackend(dir: string): TranscriptBackend {
|
|
|
54
55
|
return {
|
|
55
56
|
locate: (sessionId) => `${dir}/${sessionId}.ndjson`,
|
|
56
57
|
read: async (sessionId) => {
|
|
57
|
-
|
|
58
|
-
try { return await fs.readFile(await fileOf(sessionId), "utf8"); } catch { return null; }
|
|
58
|
+
return readPrivateFileSync(await fileOf(sessionId));
|
|
59
59
|
},
|
|
60
60
|
append: async (sessionId, line) => {
|
|
61
|
-
|
|
62
|
-
await fs.mkdir(dir, { recursive: true });
|
|
63
|
-
await fs.appendFile(await fileOf(sessionId), `${line}\n`, "utf8");
|
|
61
|
+
appendPrivateFileSync(await fileOf(sessionId), `${line}\n`);
|
|
64
62
|
},
|
|
65
63
|
rewrite: async (sessionId, text) => {
|
|
66
|
-
|
|
67
|
-
await fs.mkdir(dir, { recursive: true });
|
|
68
|
-
await fs.writeFile(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`, "utf8");
|
|
64
|
+
atomicWritePrivateFileSync(await fileOf(sessionId), text.length === 0 ? "" : `${text}\n`);
|
|
69
65
|
},
|
|
70
66
|
};
|
|
71
67
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* ready to use) both need the exact same answer, so it lives here once.
|
|
8
8
|
*/
|
|
9
9
|
import { useEffect, useState } from "indusagi/react-host";
|
|
10
|
+
import { getEnvApiKey } from "indusagi/ai";
|
|
10
11
|
import { hasValidServerToken } from "../boot/server-token.js";
|
|
11
12
|
import { serverTierProviders } from "../boot/runners/server-mode.js";
|
|
12
13
|
import type { OverlayServices } from "./contract.js";
|
|
@@ -27,10 +28,14 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
|
|
|
27
28
|
void (async () => {
|
|
28
29
|
const found = new Set<string>();
|
|
29
30
|
try {
|
|
30
|
-
|
|
31
|
+
const providers = new Set([
|
|
32
|
+
...services.listLoginProviders().map(provider => provider.id),
|
|
33
|
+
...services.conductor.availableModels().map(model => model.provider)
|
|
34
|
+
]);
|
|
35
|
+
for (const provider of providers) {
|
|
31
36
|
try {
|
|
32
|
-
if ((await services.vault.listAccounts(provider
|
|
33
|
-
found.add(provider
|
|
37
|
+
if (getEnvApiKey(provider) || (await services.vault.listAccounts(provider)).length > 0) {
|
|
38
|
+
found.add(provider);
|
|
34
39
|
}
|
|
35
40
|
} catch {
|
|
36
41
|
// ignore
|
|
@@ -51,6 +56,6 @@ export function useAuthenticatedProviders(services: OverlayServices | undefined,
|
|
|
51
56
|
return () => {
|
|
52
57
|
live = false;
|
|
53
58
|
};
|
|
54
|
-
}, [refreshKey]);
|
|
59
|
+
}, [services, refreshKey]);
|
|
55
60
|
return authed;
|
|
56
61
|
}
|
|
@@ -5,7 +5,7 @@ import { useState } from "indusagi/react-host";
|
|
|
5
5
|
import { Box, Text, useInput } from "indusagi/react-host/ink";
|
|
6
6
|
import { wrapSelectionIndex, type InkThemeAdapter } from "indusagi/react-ink";
|
|
7
7
|
import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
|
|
8
|
-
import type
|
|
8
|
+
import { useAgentClock, type AgentRun } from "./BackgroundAgents.js";
|
|
9
9
|
|
|
10
10
|
function formatTokens(tokens: number): string {
|
|
11
11
|
return tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}k` : String(tokens);
|
|
@@ -66,7 +66,7 @@ export interface AgentsViewProps {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function AgentsView({ theme, runs, activity, tokens, now, onExit }: AgentsViewProps) {
|
|
69
|
-
const clock =
|
|
69
|
+
const clock = useAgentClock(runs.some(run => run.status === "running"), now);
|
|
70
70
|
const [selectedIndex, setSelectedIndex] = useState(runs.length > 0 ? 1 : 0);
|
|
71
71
|
const rowCount = runs.length + 1;
|
|
72
72
|
const index = Math.min(selectedIndex, rowCount - 1);
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* BackgroundAgents — the live sub-agent panel pinned below the input.
|
|
3
3
|
*/
|
|
4
4
|
import { Box, Text } from "indusagi/react-host/ink";
|
|
5
|
+
import { useEffect, useState } from "indusagi/react-host";
|
|
5
6
|
import { splitAssistantMessage, type InkThemeAdapter } from "indusagi/react-ink";
|
|
6
7
|
import { jsx, jsxs } from "indusagi/react-host/jsx-runtime";
|
|
7
8
|
import type { AgentMessage } from "indusagi/agent";
|
|
@@ -100,11 +101,23 @@ export interface BackgroundAgentsProps {
|
|
|
100
101
|
/**
|
|
101
102
|
* Render the background-agents panel, or nothing when no run is in flight.
|
|
102
103
|
*/
|
|
104
|
+
export function useAgentClock(active: boolean, supplied?: number): number {
|
|
105
|
+
const [now, setNow] = useState(Date.now);
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!active || supplied !== undefined) return;
|
|
108
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
109
|
+
return () => clearInterval(timer);
|
|
110
|
+
}, [active, supplied]);
|
|
111
|
+
return supplied ?? now;
|
|
112
|
+
}
|
|
113
|
+
|
|
103
114
|
export function BackgroundAgents({ theme, runs, tokens, now }: BackgroundAgentsProps) {
|
|
104
|
-
const clock =
|
|
115
|
+
const clock = useAgentClock(runs.some(run => run.status === "running"), now);
|
|
105
116
|
const runningCount = runs.reduce((n, run) => (run.status === "running" ? n + 1 : n), 0);
|
|
106
117
|
if (runningCount === 0) return null;
|
|
107
|
-
const
|
|
118
|
+
const running = runs.filter(run => run.status === "running");
|
|
119
|
+
const recentDone = runs.filter(run => run.status !== "running").slice(-Math.max(0, MAX_VISIBLE_RUNS - running.length));
|
|
120
|
+
const visible = [...running, ...recentDone].slice(0, MAX_VISIBLE_RUNS);
|
|
108
121
|
return jsxs(Box, {
|
|
109
122
|
flexDirection: "column",
|
|
110
123
|
marginTop: 1,
|
|
@@ -9,7 +9,9 @@ import { basename } from "node:path";
|
|
|
9
9
|
import type { InkThemeAdapter } from "indusagi/react-ink";
|
|
10
10
|
import type { StartupChangelog, StartupMap, StartupNotice } from "../startup.js";
|
|
11
11
|
import { MASCOTS, WHATS_NEW, pickWelcomeVariant, mascotRows, type MascotFace } from "./welcome.js";
|
|
12
|
-
import { welcomeLine } from "./banner-sweep.js";
|
|
12
|
+
import { welcomeLine, rowGradient } from "./banner-sweep.js";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { useTerminalSize } from "./ScrollViewport.js";
|
|
13
15
|
|
|
14
16
|
const BRAND = "IndusCode";
|
|
15
17
|
const COMPACT_EMBLEM = "▛▜▙▟";
|
|
@@ -23,6 +25,11 @@ const WORDMARK_ROWS: readonly string[] = [
|
|
|
23
25
|
"╚═╝╚═╝ ╚═══╝╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝"
|
|
24
26
|
];
|
|
25
27
|
|
|
28
|
+
function StartupSummary({theme, startup}: {theme: InkThemeAdapter; startup?: StartupMap}) {
|
|
29
|
+
if (!startup?.sections.length) return null;
|
|
30
|
+
return jsx(Text, {wrap:"truncate-end", children:theme.muted(startup.sections.map(section => `${section.title}: ${section.count}`).join(" · "))});
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
function StartupNotices({ theme, notices }: { theme: InkThemeAdapter; notices: readonly StartupNotice[] }) {
|
|
27
34
|
if (notices.length === 0) return null;
|
|
28
35
|
return jsx(Box, {
|
|
@@ -98,10 +105,10 @@ function ShellLine({ theme, workspace, branch }: { theme: InkThemeAdapter; works
|
|
|
98
105
|
});
|
|
99
106
|
}
|
|
100
107
|
|
|
101
|
-
function Wordmark({ theme }: { theme: InkThemeAdapter }) {
|
|
108
|
+
function Wordmark({ theme, sweep }: { theme: InkThemeAdapter; sweep?: boolean }) {
|
|
102
109
|
return jsx(Box, {
|
|
103
110
|
flexDirection: "column",
|
|
104
|
-
children: WORDMARK_ROWS.map((line, index) => jsx(Text, { children: theme.color("accent", line) }, index))
|
|
111
|
+
children: WORDMARK_ROWS.map((line, index) => jsx(Text, { children: sweep ? chalk.hex(rowGradient(index, WORDMARK_ROWS.length, theme.colors.accent, theme.colors.customMessage))(line) : theme.color("accent", line) }, index))
|
|
105
112
|
});
|
|
106
113
|
}
|
|
107
114
|
|
|
@@ -127,7 +134,7 @@ function paintMascot(theme: InkThemeAdapter, tone: string, text: string) {
|
|
|
127
134
|
}
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
function Mascot({ theme, face }: { theme: InkThemeAdapter; face: MascotFace }) {
|
|
137
|
+
function Mascot({ theme, face, sweep }: { theme: InkThemeAdapter; face: MascotFace; sweep?: boolean }) {
|
|
131
138
|
return jsx(Box, {
|
|
132
139
|
flexDirection: "column",
|
|
133
140
|
alignItems: "center",
|
|
@@ -137,7 +144,7 @@ function Mascot({ theme, face }: { theme: InkThemeAdapter; face: MascotFace }) {
|
|
|
137
144
|
Box,
|
|
138
145
|
{
|
|
139
146
|
children: row.map((seg, segIndex) =>
|
|
140
|
-
jsx(Text, { children: paintMascot(theme, seg.tone, seg.text) }, segIndex)
|
|
147
|
+
jsx(Text, { children: sweep && seg.tone === "head" ? chalk.hex(rowGradient(rowIndex, 5, theme.colors.accent, theme.colors.customMessage))(seg.text) : paintMascot(theme, seg.tone, seg.text) }, segIndex)
|
|
141
148
|
)
|
|
142
149
|
},
|
|
143
150
|
rowIndex
|
|
@@ -174,6 +181,7 @@ export interface BannerProps {
|
|
|
174
181
|
}
|
|
175
182
|
|
|
176
183
|
export function Banner(props: BannerProps) {
|
|
184
|
+
const { columns } = useTerminalSize();
|
|
177
185
|
const {
|
|
178
186
|
theme,
|
|
179
187
|
modelId,
|
|
@@ -187,15 +195,16 @@ export function Banner(props: BannerProps) {
|
|
|
187
195
|
variantSeed,
|
|
188
196
|
whatsNew,
|
|
189
197
|
notices,
|
|
190
|
-
changelog
|
|
198
|
+
changelog, sweep, startup
|
|
191
199
|
} = props;
|
|
192
200
|
const noticeList = notices ?? [];
|
|
193
|
-
if (quiet || compact) {
|
|
201
|
+
if (quiet || compact || columns < 76) {
|
|
194
202
|
return jsxs(Box, {
|
|
195
203
|
flexDirection: "column",
|
|
196
204
|
marginBottom: 1,
|
|
197
205
|
children: [
|
|
198
206
|
jsx(StartupNotices, { theme, notices: noticeList }),
|
|
207
|
+
jsx(StartupSummary, {theme, startup}),
|
|
199
208
|
jsxs(Box, {
|
|
200
209
|
children: [
|
|
201
210
|
compact
|
|
@@ -225,8 +234,9 @@ export function Banner(props: BannerProps) {
|
|
|
225
234
|
marginBottom: 1,
|
|
226
235
|
children: [
|
|
227
236
|
jsx(StartupNotices, { theme, notices: noticeList }),
|
|
237
|
+
jsx(StartupSummary, {theme, startup}),
|
|
228
238
|
jsx(ShellLine, { theme, workspace, branch }),
|
|
229
|
-
jsx(Box, { marginTop: 1, children: jsx(Wordmark, { theme }) }),
|
|
239
|
+
jsx(Box, { marginTop: 1, children: jsx(Wordmark, { theme, sweep }) }),
|
|
230
240
|
jsxs(Box, {
|
|
231
241
|
marginTop: 1,
|
|
232
242
|
flexDirection: "column",
|
|
@@ -266,6 +276,7 @@ export function Banner(props: BannerProps) {
|
|
|
266
276
|
marginBottom: 1,
|
|
267
277
|
children: [
|
|
268
278
|
jsx(StartupNotices, { theme, notices: noticeList }),
|
|
279
|
+
jsx(StartupSummary, {theme, startup}),
|
|
269
280
|
jsxs(Box, {
|
|
270
281
|
flexDirection: "column",
|
|
271
282
|
borderStyle: "round",
|
|
@@ -292,7 +303,7 @@ export function Banner(props: BannerProps) {
|
|
|
292
303
|
marginRight: 2,
|
|
293
304
|
children: [
|
|
294
305
|
jsx(Text, { bold: true, children: theme.color("text", greeting) }),
|
|
295
|
-
jsx(Mascot, { theme, face: variant.mascot }),
|
|
306
|
+
jsx(Mascot, { theme, face: variant.mascot, sweep }),
|
|
296
307
|
jsx(Text, { children: theme.dim(metaLine) }),
|
|
297
308
|
jsx(Text, { children: theme.dim(shortenWorkspace(workspace)) })
|
|
298
309
|
]
|
|
@@ -1,29 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import type {
|
|
7
|
-
import
|
|
1
|
+
/** Bounded, grapheme-aware editor and selection-following completion menu. */
|
|
2
|
+
import { Box, Text } from 'indusagi/react-host/ink';
|
|
3
|
+
import { jsx, jsxs } from 'indusagi/react-host/jsx-runtime';
|
|
4
|
+
import stringWidth from 'string-width';
|
|
5
|
+
import type { InkThemeAdapter } from 'indusagi/react-ink';
|
|
6
|
+
import type { Suggestion } from '../input/index.js';
|
|
7
|
+
import { snapGrapheme } from '../input/graphemes.js';
|
|
8
|
+
import { useTerminalSize } from './ScrollViewport.js';
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
10
|
+
const segmenter = new Intl.Segmenter(undefined, {granularity:'grapheme'});
|
|
11
|
+
interface Cell { text: string; caret: boolean }
|
|
12
|
+
function editorLines(buffer: string, caret: number, width: number) {
|
|
13
|
+
const lines: Cell[][] = [[]];
|
|
14
|
+
let column = 0;
|
|
15
|
+
caret = snapGrapheme(buffer, caret);
|
|
16
|
+
for (const {segment, index} of segmenter.segment(buffer + ' ')) {
|
|
17
|
+
const cursor = index === caret;
|
|
18
|
+
if (segment === '\n') {
|
|
19
|
+
if (cursor) lines.at(-1)!.push({text:' ', caret:true});
|
|
20
|
+
lines.push([]); column=0; continue;
|
|
21
|
+
}
|
|
22
|
+
const text = segment === '\t' ? ' ' : segment.replace(/[\x00-\x1f\x7f]/g,'');
|
|
23
|
+
const cells = stringWidth(text);
|
|
24
|
+
if (column + cells > width && column > 0) {lines.push([]);column=0;}
|
|
25
|
+
lines.at(-1)!.push({text:cells > width ? '?' : text,caret:cursor});
|
|
26
|
+
column += Math.min(width,cells);
|
|
27
|
+
}
|
|
28
|
+
return lines;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export interface ComposerProps {
|
|
@@ -33,62 +35,35 @@ export interface ComposerProps {
|
|
|
33
35
|
readonly busy: boolean;
|
|
34
36
|
readonly suggestions: readonly Suggestion[];
|
|
35
37
|
readonly highlight: number;
|
|
38
|
+
readonly paddingX?: number;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
export function Composer({
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}),
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const active = index === highlight;
|
|
68
|
-
const name = clampName(suggestion.label, nameWidth);
|
|
69
|
-
return jsxs(
|
|
70
|
-
Box,
|
|
71
|
-
{
|
|
72
|
-
children: [
|
|
73
|
-
jsx(Box, {
|
|
74
|
-
width: 2,
|
|
75
|
-
flexShrink: 0,
|
|
76
|
-
children: jsx(Text, { children: active ? theme.color("accent", "▸") : " " })
|
|
77
|
-
}),
|
|
78
|
-
jsx(Box, {
|
|
79
|
-
width: nameWidth,
|
|
80
|
-
flexShrink: 0,
|
|
81
|
-
children: jsx(Text, { bold: active, children: theme.color("customMessage", name) })
|
|
82
|
-
}),
|
|
83
|
-
jsx(Text, { children: " " }),
|
|
84
|
-
jsx(Text, { children: theme.muted(suggestion.detail) })
|
|
85
|
-
]
|
|
86
|
-
},
|
|
87
|
-
suggestion.value
|
|
88
|
-
);
|
|
89
|
-
})
|
|
90
|
-
})
|
|
91
|
-
: null
|
|
92
|
-
]
|
|
93
|
-
});
|
|
94
|
-
}
|
|
41
|
+
export function Composer({theme,buffer,caret,suggestions,highlight,paddingX=1}: ComposerProps) {
|
|
42
|
+
const {columns,rows} = useTerminalSize();
|
|
43
|
+
const padding = Math.max(0, Math.min(3, Math.floor(paddingX), Math.floor((columns-8)/2)));
|
|
44
|
+
const sendWidth = columns >= 32 ? 7 : 0;
|
|
45
|
+
const width = Math.max(1,columns - 2 - padding*2 - 2 - sendWidth);
|
|
46
|
+
const allLines = editorLines(buffer,caret,width);
|
|
47
|
+
const caretRow = Math.max(0,allLines.findIndex(line=>line.some(cell=>cell.caret)));
|
|
48
|
+
const editorBudget = Math.max(1,Math.min(6,Math.floor(rows/4)));
|
|
49
|
+
const top = Math.max(0,Math.min(caretRow-editorBudget+1,allLines.length-editorBudget));
|
|
50
|
+
const lines = allLines.slice(top,top+editorBudget);
|
|
51
|
+
const budget = Math.max(1,Math.min(8,rows-lines.length-12));
|
|
52
|
+
const start = Math.max(0,Math.min(highlight-budget+1,suggestions.length-budget));
|
|
53
|
+
const shown = suggestions.slice(start,start+budget);
|
|
54
|
+
const nameWidth = Math.max(1,Math.min(24,Math.floor(columns/3)));
|
|
55
|
+
return jsxs(Box,{flexDirection:'column',flexShrink:0,children:[
|
|
56
|
+
jsx(Box,{borderStyle:'round',borderColor:theme.colors.borderMuted??'gray',paddingX:padding,flexDirection:'column',children:
|
|
57
|
+
lines.map((line,index)=>jsxs(Box,{height:1,flexShrink:0,children:[
|
|
58
|
+
jsx(Box,{width:2,flexShrink:0,children:jsx(Text,{children:theme.color('customMessage',index+top===0?'› ':'· ')})}),
|
|
59
|
+
jsx(Box,{width,flexShrink:0,children:jsx(Text,{wrap:'truncate-end',children:line.map((cell,i)=>jsx(Text,{inverse:cell.caret,children:cell.text},i))})}),
|
|
60
|
+
sendWidth ? jsx(Box,{width:sendWidth,flexShrink:0,children:jsx(Text,{children:index===0?theme.dim(' ↵ send'):''})}) : null
|
|
61
|
+
]},index))
|
|
62
|
+
}),
|
|
63
|
+
shown.length ? jsx(Box,{flexDirection:'column',marginTop:1,children:shown.map((suggestion,index)=>jsxs(Box,{height:1,children:[
|
|
64
|
+
jsx(Box,{width:2,flexShrink:0,children:jsx(Text,{children:start+index===highlight?theme.color('accent','▸'):' '})}),
|
|
65
|
+
jsx(Box,{width:nameWidth,flexShrink:0,children:jsx(Text,{wrap:'truncate-end',bold:start+index===highlight,children:theme.color('customMessage',suggestion.label)})}),
|
|
66
|
+
jsx(Box,{width:Math.max(1,columns-nameWidth-2),children:jsx(Text,{wrap:'truncate-end',children:theme.muted(' '+suggestion.detail)})})
|
|
67
|
+
]},suggestion.value))}) : null
|
|
68
|
+
]});
|
|
69
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'indusagi/react-host';
|
|
2
|
+
import { Box, useInput } from 'indusagi/react-host/ink';
|
|
3
|
+
import { measureElement, useStdout, type DOMElement } from 'ink';
|
|
4
|
+
import { jsx } from 'indusagi/react-host/jsx-runtime';
|
|
5
|
+
import type { ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
export function useTerminalSize() {
|
|
8
|
+
const { stdout } = useStdout();
|
|
9
|
+
const read = () => ({ columns: Math.max(1, stdout.columns || 80), rows: Math.max(1, stdout.rows || 24) });
|
|
10
|
+
const [size, setSize] = useState(read);
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const resize = () => setSize(read());
|
|
13
|
+
stdout.on('resize', resize);
|
|
14
|
+
return () => { stdout.off('resize', resize); };
|
|
15
|
+
}, [stdout]);
|
|
16
|
+
return size;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Row-bounded Ink viewport. Page keys scroll; new output follows only at the bottom. */
|
|
20
|
+
export function ScrollViewport({ children, active = true, follow = true }: { children: ReactNode; active?: boolean; follow?: boolean }) {
|
|
21
|
+
const outer = React.useRef<DOMElement>(null);
|
|
22
|
+
const inner = React.useRef<DOMElement>(null);
|
|
23
|
+
const [metrics, setMetrics] = useState({ height: 1, content: 1 });
|
|
24
|
+
const [offset, setOffset] = useState(0);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!outer.current || !inner.current) return;
|
|
27
|
+
const height = measureElement(outer.current).height;
|
|
28
|
+
const content = measureElement(inner.current).height;
|
|
29
|
+
if (metrics.height !== height || metrics.content !== content) {
|
|
30
|
+
const oldMax = Math.max(0, metrics.content - metrics.height);
|
|
31
|
+
const newMax = Math.max(0, content - height);
|
|
32
|
+
if (follow) setOffset(n => n === 0 ? 0 : Math.max(0, Math.min(newMax, n + newMax - oldMax)));
|
|
33
|
+
setMetrics({height, content});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const max = Math.max(0, metrics.content - metrics.height);
|
|
37
|
+
const scroll = Math.min(max, offset);
|
|
38
|
+
useInput((_input, key) => {
|
|
39
|
+
const step = Math.max(1, metrics.height - 2);
|
|
40
|
+
if (key.pageUp) setOffset(n => Math.min(max, n + (follow ? step : -step)));
|
|
41
|
+
if (key.pageDown) setOffset(n => Math.max(0, Math.min(max, n + (follow ? -step : step))));
|
|
42
|
+
}, {isActive: active});
|
|
43
|
+
const top = follow ? Math.max(0, max - scroll) : Math.max(0, scroll);
|
|
44
|
+
return jsx(Box, {ref: outer, flexDirection:'column', flexGrow:1, flexBasis:0, minHeight:1, overflow:'hidden',
|
|
45
|
+
children:jsx(Box, {ref:inner, flexDirection:'column', flexShrink:0, marginTop:-top, children})});
|
|
46
|
+
}
|