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
package/src/console/contract.ts
CHANGED
|
@@ -46,10 +46,10 @@
|
|
|
46
46
|
*/
|
|
47
47
|
import type { InkThemeAdapter, SessionSnapshot, StatusMessage, ToolExecutionState, UiDisplayBlock } from "indusagi/react-ink";
|
|
48
48
|
import type { Dispatch, ReactNode, Reducer } from "react";
|
|
49
|
-
import type { ConductorState, SessionConductor, SessionSignal } from "../conductor.js";
|
|
50
|
-
import type { PreferenceStore } from "../settings.js";
|
|
51
|
-
import type { SessionLibrary } from "../sessions.js";
|
|
52
|
-
import type { AuthVault, LoginProvider, OAuthLoginResult } from "../launch.js";
|
|
49
|
+
import type { ConductorState, SessionConductor, SessionSignal } from "../conductor/index.js";
|
|
50
|
+
import type { PreferenceStore } from "../settings/index.js";
|
|
51
|
+
import type { SessionLibrary } from "../sessions/index.js";
|
|
52
|
+
import type { AuthVault, LoginProvider, OAuthLoginResult } from "../launch/index.js";
|
|
53
53
|
import type { OAuthLoginCallbacks } from "indusagi/ai";
|
|
54
54
|
/** Re-exported framework vocabulary that console consumers routinely need. */
|
|
55
55
|
export type { SessionSnapshot, ToolExecutionState, StatusMessage, UiDisplayBlock, InkThemeAdapter, };
|
|
@@ -358,7 +358,11 @@ export interface ConsoleState {
|
|
|
358
358
|
* - `busy:set` — set the in-flight flag.
|
|
359
359
|
* - `tick` — bump the status-bar re-render tick.
|
|
360
360
|
*/
|
|
361
|
-
export type ConsoleEvent = {
|
|
361
|
+
export type ConsoleEvent = { readonly type: "view:reset" } | {
|
|
362
|
+
readonly type: "display:set";
|
|
363
|
+
readonly key: "showReasoning" | "showImages";
|
|
364
|
+
readonly value: boolean;
|
|
365
|
+
} | {
|
|
362
366
|
readonly type: "buffer:set";
|
|
363
367
|
readonly text: string;
|
|
364
368
|
} | {
|
|
@@ -485,6 +489,7 @@ export type SlashOutcome = {
|
|
|
485
489
|
* with respect to React internals and lets tests pass a scripted fake.
|
|
486
490
|
*/
|
|
487
491
|
export interface SlashContext {
|
|
492
|
+
readonly services?: OverlayServices;
|
|
488
493
|
/** The raw argument string after the command name (may be empty). */
|
|
489
494
|
readonly args: string;
|
|
490
495
|
/** The session this console drives. */
|
package/src/console/index.ts
CHANGED
|
@@ -22,13 +22,13 @@ export { consoleReducer, initConsoleState } from "./reducer.js";
|
|
|
22
22
|
export { THEMES, THEME_SCHEMES, resolveTheme, MIDNIGHT_PALETTE, DAYLIGHT_PALETTE, PALETTES, deriveTokens, themeAdapter, frameworkColors, } from "./theme/index.js";
|
|
23
23
|
export { SLASH_COMMANDS, FAMILY, DEFAULT_SLASH_REGISTRY, buildRegistry, tokensOf, findCommand, matchPrefix, listFamilies, commandsInFamily, SLASH_PREFIX, looksLikeSlash, parseSlash, resolveSlash, type SlashLine, type SlashResolution, } from "./slash/index.js";
|
|
24
24
|
export { NO_INTENT, readKey, NO_CHORD, advanceChord, type KeyChord, type ConsoleVerb, type ConsoleIntent, type ChordOutcome, type ChordLatch, type ChordStep, PASTE_THRESHOLDS, PASTE_BURST_GAP_MS, EMPTY_PASTE_VAULT, pasteMarker, classifyPaste, coalescePaste, expandMarkers, hasMarkers, type PasteThresholds, type PasteVault, type PasteCapture, completeAt, applySuggestion, type CompletionKind, type Suggestion, type TokenSpan, type CompletionResult, type DirEntry, type DirReader, } from "./input/index.js";
|
|
25
|
-
export { TerminalConsole } from "./components/TerminalConsole";
|
|
26
|
-
export { Composer, type ComposerProps } from "./components/Composer";
|
|
27
|
-
export { StatusBar, type StatusBarProps } from "./components/StatusBar";
|
|
28
|
-
export { Banner, type BannerProps } from "./components/Banner";
|
|
29
|
-
export { BackgroundAgents, deriveAgentRuns, type BackgroundAgentsProps, type AgentRun, type AgentRunStatus, } from "./components/BackgroundAgents";
|
|
30
|
-
export { AgentsView, type AgentsViewProps } from "./components/AgentsView";
|
|
31
|
-
export { MASCOTS, TIPS, WHATS_NEW, mascotRows, pickWelcomeVariant, randomWelcomeSeed, type MascotFace, type MascotTone, type MascotSegment, type WelcomeVariant, } from "./components/welcome";
|
|
25
|
+
export { TerminalConsole } from "./components/TerminalConsole.js";
|
|
26
|
+
export { Composer, type ComposerProps } from "./components/Composer.js";
|
|
27
|
+
export { StatusBar, type StatusBarProps } from "./components/StatusBar.js";
|
|
28
|
+
export { Banner, type BannerProps } from "./components/Banner.js";
|
|
29
|
+
export { BackgroundAgents, deriveAgentRuns, type BackgroundAgentsProps, type AgentRun, type AgentRunStatus, } from "./components/BackgroundAgents.js";
|
|
30
|
+
export { AgentsView, type AgentsViewProps } from "./components/AgentsView.js";
|
|
31
|
+
export { MASCOTS, TIPS, WHATS_NEW, mascotRows, pickWelcomeVariant, randomWelcomeSeed, type MascotFace, type MascotTone, type MascotSegment, type WelcomeVariant, } from "./components/welcome.js";
|
|
32
32
|
export { gatherStartup, gatherChangelog } from "./startup.js";
|
|
33
33
|
export type { StartupMap, StartupSection, StartupChangelog, StartupChangelogMode, StartupNotice, StartupNoticeKind, StartupInputs, } from "./startup.js";
|
|
34
34
|
export { mountConsole, type MountConsoleOptions, type MountResult, } from "./mount.js";
|
|
@@ -76,13 +76,13 @@ function splitPathStem(stem: string) {
|
|
|
76
76
|
const body = stem.slice(sigil.length);
|
|
77
77
|
const slash = body.lastIndexOf("/");
|
|
78
78
|
if (slash < 0) return { dir: ".", leaf: body, sigil };
|
|
79
|
-
return { dir: body.slice(0, slash) || "
|
|
79
|
+
return { dir: body.slice(0, slash) || "/", leaf: body.slice(slash + 1), sigil };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
function completePath(stem: string, readDir: DirReader): Suggestion[] {
|
|
83
83
|
const { dir, leaf, sigil } = splitPathStem(stem);
|
|
84
84
|
const lowerLeaf = leaf.toLowerCase();
|
|
85
|
-
const prefix = dir === "." ? "" : `${dir}/`;
|
|
85
|
+
const prefix = dir === "." ? "" : dir === "/" ? "/" : `${dir}/`;
|
|
86
86
|
const matches = readDir(dir)
|
|
87
87
|
.filter((e) => e.name.toLowerCase().startsWith(lowerLeaf))
|
|
88
88
|
.sort((a, b) => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { readdirSync, statSync } from "node:fs";
|
|
5
5
|
import { resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
6
7
|
import type { DirReader } from "./complete.js";
|
|
7
8
|
|
|
8
9
|
function entryIsDir(base: string, name: string, isDirent: boolean, isSymlink: boolean) {
|
|
@@ -20,7 +21,8 @@ function entryIsDir(base: string, name: string, isDirent: boolean, isSymlink: bo
|
|
|
20
21
|
*/
|
|
21
22
|
export function createDirReader(cwd: string = process.cwd()): DirReader {
|
|
22
23
|
return (dirPath: string) => {
|
|
23
|
-
const
|
|
24
|
+
const expanded = dirPath === "~" ? homedir() : dirPath.startsWith("~/") ? resolve(homedir(), dirPath.slice(2)) : dirPath;
|
|
25
|
+
const target = resolve(cwd, expanded);
|
|
24
26
|
try {
|
|
25
27
|
const dirents = readdirSync(target, { withFileTypes: true });
|
|
26
28
|
return dirents.map((d) => ({
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
2
|
+
export function previousGrapheme(text: string, at: number): number {
|
|
3
|
+
let previous = 0;
|
|
4
|
+
for (const part of segmenter.segment(text)) {
|
|
5
|
+
if (part.index >= at) break;
|
|
6
|
+
previous = part.index;
|
|
7
|
+
}
|
|
8
|
+
return previous;
|
|
9
|
+
}
|
|
10
|
+
export function nextGrapheme(text: string, at: number): number {
|
|
11
|
+
for (const part of segmenter.segment(text)) if (part.index > at) return part.index;
|
|
12
|
+
return text.length;
|
|
13
|
+
}
|
|
14
|
+
export function moveGrapheme(text: string, at: number, delta: number): number {
|
|
15
|
+
let next = Math.max(0, Math.min(at, text.length));
|
|
16
|
+
for (let i = 0; i < Math.abs(delta); i++) next = delta < 0 ? previousGrapheme(text, next) : nextGrapheme(text, next);
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
export function snapGrapheme(text: string, at: number): number {
|
|
20
|
+
const clamped = Math.max(0, Math.min(at, text.length));
|
|
21
|
+
return clamped === text.length ? clamped : previousGrapheme(text, clamped + 1);
|
|
22
|
+
}
|
|
@@ -52,6 +52,7 @@ export type ConsoleVerb =
|
|
|
52
52
|
|
|
53
53
|
export interface ConsoleIntent {
|
|
54
54
|
readonly verb: ConsoleVerb;
|
|
55
|
+
readonly direction?: -1 | 1;
|
|
55
56
|
readonly text?: string;
|
|
56
57
|
readonly overlay?: ModalKind;
|
|
57
58
|
}
|
|
@@ -92,7 +93,7 @@ export function readKey(input: string, key: KeyChord): ConsoleIntent {
|
|
|
92
93
|
case "n":
|
|
93
94
|
return { verb: "flow:cycleModel" };
|
|
94
95
|
case "p":
|
|
95
|
-
return { verb: "flow:cycleModel" };
|
|
96
|
+
return { verb: "flow:cycleModel", direction: -1 };
|
|
96
97
|
case "t":
|
|
97
98
|
return { verb: "view:toggleReasoning" };
|
|
98
99
|
case "v":
|
|
@@ -117,15 +118,16 @@ export function readKey(input: string, key: KeyChord): ConsoleIntent {
|
|
|
117
118
|
}
|
|
118
119
|
if (key.escape) return { verb: "flow:dismiss" };
|
|
119
120
|
if (key.tab) return key.shift ? { verb: "mode:cyclePermission" } : { verb: "flow:accept" };
|
|
120
|
-
if (key.
|
|
121
|
+
if (key.delete) return { verb: "edit:eraseNext" };
|
|
122
|
+
if (key.backspace || input === "\x7F" || input === "\b") {
|
|
121
123
|
return { verb: "edit:erasePrev" };
|
|
122
124
|
}
|
|
123
125
|
if (key.leftArrow) return { verb: "nav:left" };
|
|
124
126
|
if (key.rightArrow) return { verb: "nav:right" };
|
|
125
127
|
if (key.upArrow) return { verb: "nav:up" };
|
|
126
128
|
if (key.downArrow) return { verb: "nav:down" };
|
|
127
|
-
|
|
128
|
-
if (key.pageDown) return
|
|
129
|
+
// Page keys belong to the transcript/overlay viewport, not the composer.
|
|
130
|
+
if (key.pageUp || key.pageDown) return NO_INTENT;
|
|
129
131
|
return isPrintable(input) ? typed(input) : NO_INTENT;
|
|
130
132
|
}
|
|
131
133
|
|
package/src/console/mount.ts
CHANGED
|
@@ -8,6 +8,38 @@ import { DEFAULT_SLASH_REGISTRY } from "./slash/index.js";
|
|
|
8
8
|
import { resolveTheme } from "./theme/index.js";
|
|
9
9
|
import type { OverlayServices, SessionConductor, SlashRegistry } from "./contract.js";
|
|
10
10
|
|
|
11
|
+
export const SYNC_OUTPUT_BEGIN = "\x1b[?2026h";
|
|
12
|
+
export const SYNC_OUTPUT_END = "\x1b[?2026l";
|
|
13
|
+
|
|
14
|
+
function supportsSynchronizedOutput(stream: NodeJS.WriteStream): boolean {
|
|
15
|
+
if (!stream.isTTY || process.env["INDUS_TUI_SYNC_OUTPUT"] === "0") return false;
|
|
16
|
+
const term = process.env["TERM"] ?? "";
|
|
17
|
+
return process.env["TMUX"] === undefined && process.env["STY"] === undefined &&
|
|
18
|
+
!term.startsWith("screen") && !term.startsWith("tmux");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Wrap complete Ink writes in DEC mode 2026 so terminals paint each frame atomically. */
|
|
22
|
+
export function synchronizedOutputStream(
|
|
23
|
+
stream: NodeJS.WriteStream,
|
|
24
|
+
enabled = supportsSynchronizedOutput(stream)
|
|
25
|
+
): NodeJS.WriteStream {
|
|
26
|
+
if (!enabled) return stream;
|
|
27
|
+
const write = (chunk: string | Uint8Array, encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), callback?: (error?: Error | null) => void) => {
|
|
28
|
+
const framed = typeof chunk === "string"
|
|
29
|
+
? `${SYNC_OUTPUT_BEGIN}${chunk}${SYNC_OUTPUT_END}`
|
|
30
|
+
: Buffer.concat([Buffer.from(SYNC_OUTPUT_BEGIN), Buffer.from(chunk), Buffer.from(SYNC_OUTPUT_END)]);
|
|
31
|
+
if (typeof encodingOrCallback === "function") return stream.write(framed, encodingOrCallback);
|
|
32
|
+
return stream.write(framed, encodingOrCallback, callback);
|
|
33
|
+
};
|
|
34
|
+
return new Proxy(stream, {
|
|
35
|
+
get(target, property) {
|
|
36
|
+
if (property === "write") return write;
|
|
37
|
+
const value = Reflect.get(target, property, target);
|
|
38
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
11
43
|
/**
|
|
12
44
|
* Options for {@link mountConsole}.
|
|
13
45
|
*/
|
|
@@ -50,7 +82,17 @@ export async function mountConsole(
|
|
|
50
82
|
instance.unmount();
|
|
51
83
|
}
|
|
52
84
|
};
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
85
|
+
const wasRaw = process.stdin.isRaw === true;
|
|
86
|
+
const stdout = synchronizedOutputStream(process.stdout);
|
|
87
|
+
const instance = render(createElement(TerminalConsole, props), { exitOnCtrlC: false, stdout });
|
|
88
|
+
try {
|
|
89
|
+
await instance.waitUntilExit();
|
|
90
|
+
return { clean: exited };
|
|
91
|
+
} finally {
|
|
92
|
+
// Ink's passive input-hook cleanup may run after waitUntilExit resolves.
|
|
93
|
+
// Restore the terminal before handing control back to the caller.
|
|
94
|
+
instance.unmount();
|
|
95
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(wasRaw);
|
|
96
|
+
if (process.stdout.isTTY) process.stdout.write("\x1b[?25h");
|
|
97
|
+
}
|
|
56
98
|
}
|
|
@@ -58,7 +58,7 @@ const SERVER_TIER_ACCOUNT_ID = "server-session";
|
|
|
58
58
|
const SERVER_TIER_PROVIDER_ID = "indus-server";
|
|
59
59
|
|
|
60
60
|
function SignOutDialog(props: OverlayGroupProps) {
|
|
61
|
-
const { services, closeModal } = props;
|
|
61
|
+
const { services, closeModal, dispatch } = props;
|
|
62
62
|
const [accounts, setAccounts] = useState<any[]>([]);
|
|
63
63
|
useEffect(() => {
|
|
64
64
|
let live = true;
|
|
@@ -112,8 +112,10 @@ function SignOutDialog(props: OverlayGroupProps) {
|
|
|
112
112
|
} else {
|
|
113
113
|
await services!.vault.remove(account.provider, account.accountId);
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
await services!.refreshGatewayRouting();
|
|
116
|
+
dispatch({type:"status:set",status:{kind:"info",text:"Signed out. Model routing refreshed."}});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
dispatch({type:"status:set",status:{kind:"error",text:error instanceof Error ? error.message : "Sign-out failed."}});
|
|
117
119
|
}
|
|
118
120
|
closeModal();
|
|
119
121
|
}
|
|
@@ -155,15 +157,22 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
|
|
|
155
157
|
})();
|
|
156
158
|
const [state, setState] = useState(() => seedOAuthState(providerId, providerName, mode));
|
|
157
159
|
const pending = useRef<((value: string) => void) | null>(null);
|
|
160
|
+
const cancelPrompt = useRef<(() => void) | null>(null);
|
|
161
|
+
const mounted = useRef(true);
|
|
162
|
+
const saving = useRef(false);
|
|
163
|
+
useEffect(() => () => { mounted.current = false; }, []);
|
|
158
164
|
const merge = (next: any) => setState((prev) => ({ ...prev, ...next }));
|
|
159
165
|
useEffect(() => {
|
|
160
166
|
if (mode !== "oauth") return;
|
|
161
167
|
let live = true;
|
|
168
|
+
const controller = new AbortController();
|
|
162
169
|
const safeMerge = (next: any) => {
|
|
163
170
|
if (live) merge(next);
|
|
164
171
|
};
|
|
165
172
|
const callbacks = {
|
|
173
|
+
signal: controller.signal,
|
|
166
174
|
onAuth: (info: any) => {
|
|
175
|
+
if (!live) return;
|
|
167
176
|
safeMerge({ authInfo: info, progress: "Opening your browser to sign in…" });
|
|
168
177
|
void services!.openLoginUrl(info.url).then((opened: boolean) => {
|
|
169
178
|
safeMerge({
|
|
@@ -174,13 +183,17 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
|
|
|
174
183
|
});
|
|
175
184
|
},
|
|
176
185
|
onPrompt: (prompt: any) =>
|
|
177
|
-
new Promise<string>((resolve) => {
|
|
178
|
-
|
|
186
|
+
new Promise<string>((resolve, reject) => {
|
|
187
|
+
if (!live) { reject(new DOMException("Sign-in cancelled", "AbortError")); return; }
|
|
188
|
+
cancelPrompt.current = () => reject(new DOMException("Sign-in cancelled", "AbortError"));
|
|
189
|
+
pending.current = value => { cancelPrompt.current = null; resolve(value); };
|
|
179
190
|
safeMerge({ prompt, inputLabel: prompt.message, inputValue: "" });
|
|
180
191
|
}),
|
|
181
192
|
onManualCodeInput: () =>
|
|
182
|
-
new Promise<string>((resolve) => {
|
|
183
|
-
|
|
193
|
+
new Promise<string>((resolve, reject) => {
|
|
194
|
+
if (!live) { reject(new DOMException("Sign-in cancelled", "AbortError")); return; }
|
|
195
|
+
cancelPrompt.current = () => reject(new DOMException("Sign-in cancelled", "AbortError"));
|
|
196
|
+
pending.current = value => { cancelPrompt.current = null; resolve(value); };
|
|
184
197
|
safeMerge({
|
|
185
198
|
prompt: { message: "Paste the authorization code or complete redirect URL:" },
|
|
186
199
|
inputLabel: "Authorization code or redirect URL",
|
|
@@ -194,7 +207,9 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
|
|
|
194
207
|
void (async () => {
|
|
195
208
|
try {
|
|
196
209
|
await services!.startOAuthLogin(providerId, callbacks, services!.vault);
|
|
210
|
+
if (!live) return;
|
|
197
211
|
await services!.refreshGatewayRouting();
|
|
212
|
+
if (!live) return;
|
|
198
213
|
selectProviderModel(services!, providerId);
|
|
199
214
|
if (live) {
|
|
200
215
|
dispatch({
|
|
@@ -209,15 +224,20 @@ function OAuthFlowDialog(props: OverlayGroupProps & { providerId: string; mode:
|
|
|
209
224
|
})();
|
|
210
225
|
return () => {
|
|
211
226
|
live = false;
|
|
227
|
+
controller.abort(); cancelPrompt.current?.(); cancelPrompt.current = null; pending.current = null;
|
|
212
228
|
};
|
|
213
229
|
}, []);
|
|
214
230
|
const saveApiKey = () => {
|
|
215
231
|
const key = (state as any).inputValue.trim();
|
|
216
|
-
if (key.length === 0) return;
|
|
232
|
+
if (key.length === 0 || saving.current) return;
|
|
233
|
+
saving.current = true;
|
|
217
234
|
const account = (state as any).accountId ?? "default";
|
|
218
235
|
void (async () => {
|
|
219
236
|
try {
|
|
220
237
|
await services!.vault.putApiKey(providerId, account, key, true);
|
|
238
|
+
if (!mounted.current) return;
|
|
239
|
+
await services!.refreshGatewayRouting();
|
|
240
|
+
if (!mounted.current) return;
|
|
221
241
|
selectProviderModel(services!, providerId);
|
|
222
242
|
dispatch({
|
|
223
243
|
type: "status:set",
|
|
@@ -2,44 +2,17 @@
|
|
|
2
2
|
* Saffron picker boards — the themed `/settings` and `/model` overlays.
|
|
3
3
|
*/
|
|
4
4
|
import chalk from "chalk";
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
5
|
import { useEffect, useMemo, useState } from "indusagi/react-host";
|
|
9
6
|
import { Box, Text, useInput } from "indusagi/react-host/ink";
|
|
10
7
|
import {
|
|
11
8
|
matchesSearchQuery,
|
|
12
9
|
wrapSelectionIndex,
|
|
13
10
|
type InkThemeAdapter,
|
|
14
|
-
type SettingsDialogItem
|
|
15
|
-
type Model
|
|
11
|
+
type SettingsDialogItem
|
|
16
12
|
} from "indusagi/react-ink";
|
|
13
|
+
import type { Model } from "indusagi/ai";
|
|
17
14
|
import { Fragment, jsx, jsxs } from "indusagi/react-host/jsx-runtime";
|
|
18
|
-
import { isGatewayEligibleProvider } from "../../boot/runners/server-mode.js";
|
|
19
15
|
|
|
20
|
-
function readServerTierInfo() {
|
|
21
|
-
let signedIn = false;
|
|
22
|
-
try {
|
|
23
|
-
const file = join(homedir(), ".indusagi", "agent", "server-token.json");
|
|
24
|
-
const token = JSON.parse(readFileSync(file, "utf8"));
|
|
25
|
-
signedIn = typeof token.expires_at === "string" && new Date(token.expires_at).getTime() - Date.now() > 5 * 60 * 1e3;
|
|
26
|
-
} catch {
|
|
27
|
-
signedIn = false;
|
|
28
|
-
}
|
|
29
|
-
const localKeys = new Set<string>();
|
|
30
|
-
try {
|
|
31
|
-
const file = join(homedir(), ".indusagi", "agent", "auth.json");
|
|
32
|
-
const vault = JSON.parse(readFileSync(file, "utf8"));
|
|
33
|
-
for (const [provider, entry] of Object.entries(vault)) {
|
|
34
|
-
if (entry !== null && typeof entry === "object" && Object.keys(entry).length > 0) {
|
|
35
|
-
localKeys.add(provider);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
} catch {
|
|
39
|
-
// ignore
|
|
40
|
-
}
|
|
41
|
-
return { signedIn, localKeys };
|
|
42
|
-
}
|
|
43
16
|
|
|
44
17
|
function truncate(text: string, width: number): string {
|
|
45
18
|
if (text.length <= width) return text;
|
|
@@ -155,6 +128,7 @@ export interface SettingsBoardProps {
|
|
|
155
128
|
}
|
|
156
129
|
|
|
157
130
|
export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
|
|
131
|
+
const [error, setError] = useState<string>();
|
|
158
132
|
const [query, setQuery] = useState("");
|
|
159
133
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
160
134
|
const filtered = useMemo(
|
|
@@ -172,7 +146,11 @@ export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
|
|
|
172
146
|
const item = filtered[selectedIndex];
|
|
173
147
|
if (!item) return;
|
|
174
148
|
const value = nextValue(item.values, item.value, direction);
|
|
175
|
-
if (value && value !== item.value)
|
|
149
|
+
if (value && value !== item.value) {
|
|
150
|
+
setError(undefined);
|
|
151
|
+
const failed = (error: unknown) => setError(error instanceof Error ? error.message : String(error));
|
|
152
|
+
try { void Promise.resolve(item.onChange(value)).catch(failed); } catch (error) { failed(error); }
|
|
153
|
+
}
|
|
176
154
|
};
|
|
177
155
|
useInput((input, key) => {
|
|
178
156
|
if (key.escape) return onClose();
|
|
@@ -187,7 +165,7 @@ export function SettingsBoard({ theme, items, onClose }: SettingsBoardProps) {
|
|
|
187
165
|
const { start, budget, below } = windowAround(filtered.length, selectedIndex);
|
|
188
166
|
const visible = filtered.slice(start, start + budget);
|
|
189
167
|
const selected = filtered[selectedIndex];
|
|
190
|
-
const detail = selected ? `${selected.description} · ${selected.values.join(" / ")}` : void 0;
|
|
168
|
+
const detail = error ?? (selected ? `${selected.description} · ${selected.values.join(" / ")}` : void 0);
|
|
191
169
|
const list =
|
|
192
170
|
items.length === 0
|
|
193
171
|
? jsx(Text, { children: theme.muted("No settings are available.") })
|
|
@@ -242,13 +220,8 @@ export function ModelBoard({ theme, models, onClose, onSelect, search }: ModelBo
|
|
|
242
220
|
const [query, setQuery] = useState(search ?? "");
|
|
243
221
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
244
222
|
useEffect(() => setQuery(search ?? ""), [search]);
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
serverTier.signedIn && isGatewayEligibleProvider(provider) && !serverTier.localKeys.has(provider);
|
|
248
|
-
const available = useMemo(
|
|
249
|
-
() => models.filter((model) => serverTier.localKeys.has(model.provider) || isServerTier(model.provider)),
|
|
250
|
-
[models, serverTier]
|
|
251
|
-
);
|
|
223
|
+
// Authentication belongs to the injected service/parent, never a guessed HOME path.
|
|
224
|
+
const available = models;
|
|
252
225
|
const filtered = useMemo(() => available.filter((model) => modelMatches(model, query)), [available, query]);
|
|
253
226
|
useEffect(() => setSelectedIndex(0), [query]);
|
|
254
227
|
useEffect(() => {
|
|
@@ -289,10 +262,10 @@ export function ModelBoard({ theme, models, onClose, onSelect, search }: ModelBo
|
|
|
289
262
|
jsx(Box, { width: 2, children: jsx(Text, { children: isSelected ? theme.color("accent", "›") : " " }) }),
|
|
290
263
|
jsx(Text, { bold: isSelected, children: theme.color("text", model.id) }),
|
|
291
264
|
model.name ? jsx(Text, { children: theme.dim(` ${model.name}`) }) : null,
|
|
292
|
-
|
|
265
|
+
jsx(Text, { children: theme.dim(` ${model.provider}`) })
|
|
293
266
|
]
|
|
294
267
|
},
|
|
295
|
-
model.id
|
|
268
|
+
`${model.provider}/${model.id}`
|
|
296
269
|
);
|
|
297
270
|
})
|
|
298
271
|
});
|
|
@@ -16,8 +16,18 @@ export interface OverlayHostProps {
|
|
|
16
16
|
readonly closeModal: () => void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
import { Box, Text, useInput } from "indusagi/react-host/ink";
|
|
20
|
+
|
|
21
|
+
function UnavailableOverlay({ closeModal }: { closeModal: () => void }) {
|
|
22
|
+
useInput((_input, key) => { if (key.escape || key.return) closeModal(); });
|
|
23
|
+
return jsx(Box, {children: jsx(Text, {children: "This dialog is unavailable in this host. Esc / Enter to return."})});
|
|
24
|
+
}
|
|
25
|
+
|
|
19
26
|
export function OverlayHost(props: OverlayHostProps) {
|
|
20
27
|
if (props.modal.kind === "none") return null;
|
|
28
|
+
if (!props.services && props.modal.kind !== "approval" && props.modal.kind !== "plugin") {
|
|
29
|
+
return jsx(UnavailableOverlay, {closeModal: props.closeModal});
|
|
30
|
+
}
|
|
21
31
|
const forwarded = {
|
|
22
32
|
modal: props.modal,
|
|
23
33
|
services: props.services,
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
* Picker overlays — the selection dialogs raised over the transcript.
|
|
3
3
|
*/
|
|
4
4
|
import { useEffect, useState } from "indusagi/react-host";
|
|
5
|
-
import { ScopedModelsDialog, ThemeDialog
|
|
5
|
+
import { ScopedModelsDialog, ThemeDialog } from "indusagi/react-ink";
|
|
6
|
+
import type { Model } from "indusagi/ai";
|
|
7
|
+
import type { ThemeScheme } from "../contract.js";
|
|
8
|
+
import type { Preferences, SettingKey } from "../../settings/contract.js";
|
|
6
9
|
import { jsx } from "indusagi/react-host/jsx-runtime";
|
|
7
10
|
import type { ConsoleDispatch, InkThemeAdapter, ModalState, OverlayServices } from "../contract.js";
|
|
8
11
|
import { DEFAULT_SCHEME, isThemeScheme } from "../contract.js";
|
|
@@ -13,6 +16,15 @@ import { ModelBoard, SettingsBoard } from "./boards.js";
|
|
|
13
16
|
|
|
14
17
|
const TOGGLE_VALUES = ["on", "off"];
|
|
15
18
|
|
|
19
|
+
function saveSetting<K extends SettingKey>(settings: OverlayServices["settings"], key: K, value: Preferences[K]) {
|
|
20
|
+
const previous = settings.get(key);
|
|
21
|
+
try { settings.set(key, value); settings.save(); }
|
|
22
|
+
catch (error) { settings.set(key, previous); throw error; }
|
|
23
|
+
}
|
|
24
|
+
function reportError(dispatch: ConsoleDispatch, error: unknown) {
|
|
25
|
+
dispatch({type:"status:set",status:{kind:"error",text:error instanceof Error ? error.message : String(error)}});
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
function refToModel(ref: any): Model<any> {
|
|
17
29
|
return {
|
|
18
30
|
id: ref.id,
|
|
@@ -37,7 +49,7 @@ function listModels(services: OverlayServices): Model<any>[] {
|
|
|
37
49
|
}
|
|
38
50
|
|
|
39
51
|
function ModelPicker(props: OverlayGroupProps) {
|
|
40
|
-
const { services, theme, closeModal } = props;
|
|
52
|
+
const { services, theme, closeModal, dispatch } = props;
|
|
41
53
|
const authed = useAuthenticatedProviders(services);
|
|
42
54
|
if (!services) return null;
|
|
43
55
|
const all = listModels(services);
|
|
@@ -48,8 +60,8 @@ function ModelPicker(props: OverlayGroupProps) {
|
|
|
48
60
|
onSelect: (model: Model<any>) => {
|
|
49
61
|
try {
|
|
50
62
|
services.conductor.selectModel(model.id);
|
|
51
|
-
} catch {
|
|
52
|
-
|
|
63
|
+
} catch (error) {
|
|
64
|
+
reportError(dispatch, error);
|
|
53
65
|
}
|
|
54
66
|
closeModal();
|
|
55
67
|
},
|
|
@@ -72,8 +84,7 @@ function ScopedModelPicker(props: OverlayGroupProps) {
|
|
|
72
84
|
useEffect(() => {
|
|
73
85
|
if (!services || payload.intent !== "reset") return;
|
|
74
86
|
try {
|
|
75
|
-
services.settings
|
|
76
|
-
services.settings.save();
|
|
87
|
+
saveSetting(services.settings, "enabledModels", []);
|
|
77
88
|
dispatch({
|
|
78
89
|
type: "status:set",
|
|
79
90
|
status: { kind: "info", text: "Per-scope model overrides cleared." }
|
|
@@ -103,10 +114,9 @@ function ScopedModelPicker(props: OverlayGroupProps) {
|
|
|
103
114
|
try {
|
|
104
115
|
const all = models.map((model) => model.id);
|
|
105
116
|
const next = ids.length === all.length ? [] : ids;
|
|
106
|
-
services.settings
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
// ignore
|
|
117
|
+
saveSetting(services.settings, "enabledModels", next);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
reportError(dispatch, error);
|
|
110
120
|
}
|
|
111
121
|
closeModal();
|
|
112
122
|
},
|
|
@@ -114,7 +124,7 @@ function ScopedModelPicker(props: OverlayGroupProps) {
|
|
|
114
124
|
});
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
function currentScheme(services: OverlayServices):
|
|
127
|
+
function currentScheme(services: OverlayServices): ThemeScheme {
|
|
118
128
|
try {
|
|
119
129
|
const stored = services.settings.get("colourScheme");
|
|
120
130
|
if (typeof stored === "string" && isThemeScheme(stored)) return stored;
|
|
@@ -136,12 +146,11 @@ function ThemePicker(props: OverlayGroupProps) {
|
|
|
136
146
|
themes: [...THEME_SCHEMES],
|
|
137
147
|
onSelect: (name: string) => {
|
|
138
148
|
if (isThemeScheme(name)) {
|
|
139
|
-
dispatch({ type: "scheme:set", scheme: name });
|
|
140
149
|
try {
|
|
141
|
-
services.settings
|
|
142
|
-
|
|
143
|
-
} catch {
|
|
144
|
-
|
|
150
|
+
saveSetting(services.settings, "colourScheme", name);
|
|
151
|
+
dispatch({ type: "scheme:set", scheme: name });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reportError(dispatch, error);
|
|
145
154
|
}
|
|
146
155
|
}
|
|
147
156
|
closeModal();
|
|
@@ -157,20 +166,19 @@ function SettingsPicker(props: OverlayGroupProps) {
|
|
|
157
166
|
const [, setVersion] = useState(0);
|
|
158
167
|
const bump = () => setVersion((n) => n + 1);
|
|
159
168
|
const onOff = (flag: boolean) => (flag ? "on" : "off");
|
|
160
|
-
const toggleRow = (id: string, key:
|
|
169
|
+
const toggleRow = (id: string, key: SettingKey, label: string, description: string, live?: (enabled: boolean) => void) => ({
|
|
161
170
|
id,
|
|
162
171
|
label,
|
|
163
172
|
description,
|
|
164
173
|
value: onOff(settings.get(key) as boolean),
|
|
165
174
|
values: [...TOGGLE_VALUES],
|
|
166
175
|
onChange: (value: string) => {
|
|
167
|
-
settings
|
|
168
|
-
|
|
169
|
-
live?.();
|
|
176
|
+
saveSetting(settings, key, value === "on");
|
|
177
|
+
live?.(value === "on");
|
|
170
178
|
bump();
|
|
171
179
|
}
|
|
172
180
|
});
|
|
173
|
-
const deliveryRow = (id: string, key:
|
|
181
|
+
const deliveryRow = (id: string, key: SettingKey, label: string, description: string) => ({
|
|
174
182
|
id,
|
|
175
183
|
label,
|
|
176
184
|
description,
|
|
@@ -178,8 +186,7 @@ function SettingsPicker(props: OverlayGroupProps) {
|
|
|
178
186
|
values: [...DELIVERY_MODES],
|
|
179
187
|
onChange: (value: string) => {
|
|
180
188
|
if (isDeliveryMode(value)) {
|
|
181
|
-
settings
|
|
182
|
-
settings.save();
|
|
189
|
+
saveSetting(settings, key, value);
|
|
183
190
|
}
|
|
184
191
|
bump();
|
|
185
192
|
}
|
|
@@ -193,16 +200,15 @@ function SettingsPicker(props: OverlayGroupProps) {
|
|
|
193
200
|
value: settings.get("colourScheme") as string,
|
|
194
201
|
values: [...THEME_SCHEMES],
|
|
195
202
|
onChange: (value: string) => {
|
|
196
|
-
settings
|
|
197
|
-
settings.save();
|
|
203
|
+
saveSetting(settings, "colourScheme", value);
|
|
198
204
|
if (isThemeScheme(value)) dispatch({ type: "scheme:set", scheme: value });
|
|
199
205
|
bump();
|
|
200
206
|
}
|
|
201
207
|
});
|
|
202
|
-
items.push(toggleRow("show-images", "showImages", "Show images", "Render inline image content in the transcript.",
|
|
208
|
+
items.push(toggleRow("show-images", "showImages", "Show images", "Render inline image content in the transcript.", value => dispatch({ type: "display:set", key: "showImages", value })));
|
|
203
209
|
items.push(toggleRow("image-auto-resize", "imageAutoResize", "Auto-resize images", "Shrink oversized images to fit before handing them to a provider."));
|
|
204
210
|
items.push(toggleRow("block-images", "blockImages", "Block images", "Withhold image content from providers entirely."));
|
|
205
|
-
items.push(toggleRow("show-reasoning", "showReasoning", "Show reasoning", "Stream the model's reasoning / thinking text as it arrives.",
|
|
211
|
+
items.push(toggleRow("show-reasoning", "showReasoning", "Show reasoning", "Stream the model's reasoning / thinking text as it arrives.", value => dispatch({ type: "display:set", key: "showReasoning", value })));
|
|
206
212
|
items.push(toggleRow("skill-commands", "enableSkillCommands", "Skill commands", "Surface discovered skills as their own slash commands."));
|
|
207
213
|
items.push(deliveryRow("steering-mode", "steeringMode", "Steering mode", "Whether queued steering corrections release one at a time or all at once."));
|
|
208
214
|
items.push(deliveryRow("follow-up-mode", "followUpMode", "Follow-up mode", "Whether queued follow-up prompts release one at a time or all at once."));
|
|
@@ -211,7 +217,7 @@ function SettingsPicker(props: OverlayGroupProps) {
|
|
|
211
217
|
items.push(toggleRow("quiet-startup", "quietStartup", "Quiet startup", "Suppress the banner and tips shown on a normal interactive launch."));
|
|
212
218
|
items.push(toggleRow("logo-sweep", "logoSweep", "Logo sweep", "Tint the startup wordmark and emblem along a static colour gradient."));
|
|
213
219
|
items.push(toggleRow("reduced-motion", "reducedMotion", "Reduced motion", "Suppress motion-flavoured flourishes such as the logo colour sweep."));
|
|
214
|
-
|
|
220
|
+
// Ink 5 owns the hardware cursor; do not offer a nonfunctional switch.
|
|
215
221
|
items.push({
|
|
216
222
|
id: "editor-padding",
|
|
217
223
|
label: "Editor padding",
|
|
@@ -221,8 +227,7 @@ function SettingsPicker(props: OverlayGroupProps) {
|
|
|
221
227
|
onChange: (value: string) => {
|
|
222
228
|
const parsed = Number.parseInt(value, 10);
|
|
223
229
|
if (Number.isFinite(parsed)) {
|
|
224
|
-
settings
|
|
225
|
-
settings.save();
|
|
230
|
+
saveSetting(settings, "editorPaddingX", parsed);
|
|
226
231
|
}
|
|
227
232
|
bump();
|
|
228
233
|
}
|