agentgui 1.0.942 → 1.0.944
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/AGENTS.md +14 -4
- package/CHANGELOG.md +12 -0
- package/lib/agent-descriptors.js +1 -0
- package/lib/agent-discovery.js +1 -0
- package/lib/claude-runner-agents.js +27 -0
- package/lib/claude-runner-direct.js +9 -3
- package/lib/claude-runner.js +22 -1
- package/package.json +1 -1
- package/site/app/index.html +14 -7
- package/site/app/js/app.js +77 -54
- package/site/app/vendor/anentrypoint-design/247420.css +40 -15
- package/site/app/vendor/anentrypoint-design/247420.js +11 -11
package/AGENTS.md
CHANGED
|
@@ -16,13 +16,23 @@ Dependencies:
|
|
|
16
16
|
- `ccsniff` (>=1.1.0) — exports `createHistoryRouter({projectsDir})` mountable on Express; serves `/v1/history/{sessions,sessions/:sid/events,search,snapshot,reindex,stream}`. Reads `~/.claude/projects` (override via `CLAUDE_PROJECTS_DIR`).
|
|
17
17
|
- `anentrypoint-design` (>=0.0.119) — kit library, single-file ESM from unpkg
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Orchestration agents
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The four flagship agents the GUI drives are **Claude Code, OpenCode, Kilo, and Antigravity (`agy`)**; the agent picker (`site/app/js/app.js`, `PRIMARY_AGENTS`) sorts these first, then other-available, then npx-installable, then not-installed.
|
|
22
|
+
|
|
23
|
+
Two runner protocols exist. **Direct** (`lib/claude-runner-direct.js`): claude-code and agy — spawn the CLI per turn, parse stdout. **ACP** (`lib/acp-sdk-manager.js`): opencode/kilo/codex — an on-demand long-lived server on ports 18100/18101/18102, health-checked via `/provider`. The on-demand start + restart-backoff is correct even when an ACP agent lacks provider auth (it reports running-not-healthy rather than crashing).
|
|
24
|
+
|
|
25
|
+
`agy` (Antigravity) is a Gemini-backed Go CLI. Its invocation is `agy --print "<prompt>" --dangerously-skip-permissions [--continue]` — `--print` is a **value flag** (the prompt is its argument; a positional prompt exits 2). It emits **plain text** (not stream-json) and prints no session id, so its `parseOutput` wraps each line into an assistant-text event and resume is `--continue`-only. A live model response needs an authenticated Antigravity session; without it `agy` returns empty (the direct runner resolves gracefully, no hang). Detail in rs-learn (recall "agentgui agy Antigravity agent").
|
|
26
|
+
|
|
27
|
+
**The direct runner spawns with `shell:false` against a resolved binary path — never `shell:true`.** `shell:true` on Windows concatenates argv without escaping, so a chat prompt containing `&`/`|`/`>`/backticks executes as shell commands (arbitrary command execution). `lib/claude-runner.js` `resolveBinaryPath` resolves the command to an absolute `.exe`; `getSpawnOptions` only defaults `shell:true` when a caller passes no explicit `shell`. Detail in rs-learn (recall "agentgui SECURITY direct runner shell:false").
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
**Agent availability comes from `registry.isAvailable(id)`** (`lib/ws-handlers-util.js`, `agents.list`), which runs `where`/`which`. A binary installed outside the system PATH reads as "(not installed)"; the fix is an `npxPackage` on the registry entry so it falls back to bun/npx presence. Detail in rs-learn (recall "agentgui agent availability where npxPackage").
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
**Output and source carry no decorative glyphs.** The GUI uses ASCII words and CSS-drawn discs (`.status-dot-disc`) for status, never `●/◌/○/⌘/§/▶` etc. Convert any decorative glyph to its ASCII equivalent on sight.
|
|
32
|
+
|
|
33
|
+
## Browser Witness
|
|
34
|
+
|
|
35
|
+
`bun server.js`. Default `PORT=3000` (server.js); the SPA is served under `BASE_URL` (default `/gm/`), so the live app is **http://localhost:3000/gm/** — `/health` and `/` answer at root, the app is under `/gm/`. First request to `/gm/` or `/v1/history/*` triggers a 30-90s ccsniff JSONL walk (curl with a short timeout returns 000 during warmup). AppShell renders nav=[chat,history,settings], SSE `hello`, 0 console errors, backend resolves to `''` (same origin).
|
|
26
36
|
|
|
27
37
|
## CI / GitHub Actions
|
|
28
38
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [Unreleased] - live e2e chat verified + composer caret fix + ccsniff memory cap
|
|
2
|
+
|
|
3
|
+
- verified the full GUI chat round-trip end-to-end in a real browser: compose -> WS chat.sendMessage -> claude-code -> streaming_progress -> transcript renders the streamed reply (not just the runner harness).
|
|
4
|
+
- site/app/js/app.js: composer onInput re-renders only on the empty<->non-empty draft transition (the only change that toggles the send button), not on every keystroke. Why: a synchronous re-render per character fought the caret on the controlled textarea.
|
|
5
|
+
- vendored anentrypoint-design DS rebuilt: ChatComposer reads the live textarea value on send and syncs the controlled value into the DOM only when it differs, so a mid-type re-render no longer resets the caret or drops keystrokes. Why: DS override belongs in the kit; the composer is shared.
|
|
6
|
+
- ccsniff (sibling repo, pushed): bounded the in-memory history store (event cap + eviction + per-event text cap) and bounded loadOnce to read newest events first up to the cap. Why: the store grew unbounded to 6GB+ (OOM) and parsed the entire JSONL backlog at load (~2.4GB transient peak); heap now settles ~47MB and load peak ~570MB.
|
|
7
|
+
|
|
8
|
+
- lib/agent-discovery.js + lib/claude-runner-agents.js + lib/agent-descriptors.js: added `agy` (Antigravity) as a direct-protocol CLI agent — `agy --print "<prompt>" --dangerously-skip-permissions [--continue]`, plain-text parseOutput, no model picker. Completes the four flagship orchestration targets (Claude Code, OpenCode, Kilo, Antigravity). Why: the GUI could not drive Antigravity at all.
|
|
9
|
+
- lib/claude-runner.js + lib/claude-runner-direct.js: SECURITY — the direct runner now spawns a resolved binary with `shell:false`. Previously `shell:true` on Windows concatenated argv unescaped, so a prompt containing `&`/`|`/`>`/backticks executed as shell commands (verified injection). Why: arbitrary command execution from chat input.
|
|
10
|
+
- lib/claude-runner-agents.js: claude-code registry entry gained `npxPackage` so it reports available via npx fallback when `where claude` misses a non-PATH install. Why: the flagship agent showed "(not installed)" despite being present.
|
|
11
|
+
- site/app/js/app.js + site/app/index.html: removed every decorative glyph (status dots, cmd/section/box/arrow/check/cross marks) in favour of ASCII words and a CSS-drawn status disc; agent picker now sorts the four flagship targets first, then available, then npx-installable, then not-installed. Why: machine-shaped glyph tells and a flat 17-item list obscured the orchestration targets.
|
|
12
|
+
|
|
1
13
|
## [Unreleased] - site/app live client migrated to 247420 SDK components
|
|
2
14
|
|
|
3
15
|
- site/app/index.html: removed 35 lines of bespoke CSS overriding design tokens; now uses SDK panel/font tokens with only structural overrides (chat flex layout, history pane grid)
|
package/lib/agent-descriptors.js
CHANGED
|
@@ -2,6 +2,7 @@ const agentDescriptorCache = new Map();
|
|
|
2
2
|
|
|
3
3
|
const AGENT_DESCRIPTIONS = {
|
|
4
4
|
'claude-code': 'Claude Code is an AI coding agent that can read, write, and execute code with streaming output support. It provides comprehensive code editing, file management, and terminal execution capabilities.',
|
|
5
|
+
'agy': 'Antigravity (agy) is a Gemini-backed coding agent CLI. It runs prompts non-interactively with plain-text streaming output, workspace directory scoping, and conversation resume.',
|
|
5
6
|
'gemini': 'Gemini CLI is Google AI coding agent with streaming support, code execution, and file management capabilities.',
|
|
6
7
|
'opencode': 'OpenCode is a multi-provider AI coding agent with streaming support and comprehensive code manipulation capabilities.',
|
|
7
8
|
};
|
package/lib/agent-discovery.js
CHANGED
|
@@ -6,6 +6,7 @@ import { execSync } from 'child_process';
|
|
|
6
6
|
|
|
7
7
|
const BINARIES = [
|
|
8
8
|
{ cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
|
|
9
|
+
{ cmd: 'agy', id: 'agy', name: 'Antigravity', icon: 'Y', protocol: 'cli' },
|
|
9
10
|
{ cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
|
|
10
11
|
{ cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
|
|
11
12
|
{ cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
|
|
@@ -97,6 +97,10 @@ function parseClaudeOutput(line) {
|
|
|
97
97
|
registry.register({
|
|
98
98
|
id: 'claude-code', name: 'Claude Code', command: 'claude', protocol: 'direct', supportsStdin: false, closeStdin: true,
|
|
99
99
|
useJsonRpcStdin: false, supportedFeatures: ['streaming', 'resume', 'system-prompt', 'permissions-skip', 'steering'],
|
|
100
|
+
// claude has an npx fallback (matches agent-discovery), so when `where claude`
|
|
101
|
+
// misses a non-PATH install the agent still reports as runnable rather than
|
|
102
|
+
// "(not installed)".
|
|
103
|
+
npxPackage: '@anthropic-ai/claude-code',
|
|
100
104
|
spawnEnv: { MAX_THINKING_TOKENS: '0', AGENTGUI_SUBPROCESS: '1' },
|
|
101
105
|
buildArgs(prompt, config) {
|
|
102
106
|
const { verbose = true, outputFormat = 'stream-json', print = true, resumeSessionId = null, systemPrompt = null, model = null } = config;
|
|
@@ -114,6 +118,29 @@ registry.register({
|
|
|
114
118
|
parseOutput: parseClaudeOutput
|
|
115
119
|
});
|
|
116
120
|
|
|
121
|
+
// Antigravity (`agy`): a direct-protocol CLI like claude-code, but emits PLAIN
|
|
122
|
+
// TEXT (no stream-json, no model flag, no printed session_id). Each stdout line
|
|
123
|
+
// is wrapped into the assistant-text event the non-JSONL stream handler expects.
|
|
124
|
+
// Resume uses --continue (most-recent conversation) since agy prints no session id.
|
|
125
|
+
registry.register({
|
|
126
|
+
id: 'agy', name: 'Antigravity', command: 'agy', protocol: 'direct',
|
|
127
|
+
supportsStdin: false, closeStdin: true,
|
|
128
|
+
supportedFeatures: ['streaming', 'resume'],
|
|
129
|
+
spawnEnv: { AGENTGUI_SUBPROCESS: '1' },
|
|
130
|
+
buildArgs(prompt, config) {
|
|
131
|
+
const { resumeSessionId = null } = config;
|
|
132
|
+
// agy's --print is a VALUE flag (the prompt is its argument), not a boolean
|
|
133
|
+
// followed by a positional. A positional prompt exits with code 2.
|
|
134
|
+
const flags = ['--print', typeof prompt === 'string' ? prompt : String(prompt), '--dangerously-skip-permissions'];
|
|
135
|
+
if (resumeSessionId) flags.push('--continue');
|
|
136
|
+
return flags;
|
|
137
|
+
},
|
|
138
|
+
parseOutput(line) {
|
|
139
|
+
const text = line.replace(/\r$/, '');
|
|
140
|
+
if (!text.trim()) return null;
|
|
141
|
+
return { type: 'assistant', message: { content: [{ type: 'text', text }] } };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
117
144
|
registry.register({ id: 'opencode', name: 'OpenCode', command: 'opencode', protocol: 'acp', supportsStdin: false, npxPackage: 'opencode-ai', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
|
|
118
145
|
registry.register({ id: 'gemini', name: 'Gemini CLI', command: 'gemini', protocol: 'acp', supportsStdin: false, npxPackage: '@google/gemini-cli', supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs(prompt, config) { const args = ['--experimental-acp', '--yolo']; if (config?.model) args.push('--model', config.model); return args; }, protocolHandler: acpProtocolHandler });
|
|
119
146
|
registry.register({ id: 'goose', name: 'Goose', command: 'goose', protocol: 'acp', supportsStdin: false, supportedFeatures: ['streaming', 'resume', 'acp-protocol'], buildArgs: () => ['acp'], protocolHandler: acpProtocolHandler });
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
|
-
import { AgentRunner, getSpawnOptions } from './claude-runner.js';
|
|
2
|
+
import { AgentRunner, getSpawnOptions, resolveBinaryPath } from './claude-runner.js';
|
|
3
3
|
|
|
4
4
|
AgentRunner.prototype.runDirect = function(prompt, cwd, config = {}) {
|
|
5
5
|
return new Promise((resolve, reject) => {
|
|
6
6
|
const { timeout = 300000, onEvent = null, onError = null, onRateLimit = null } = config;
|
|
7
7
|
const args = this.buildArgs(prompt, config);
|
|
8
|
-
|
|
8
|
+
// The direct runner passes the untrusted user prompt as an argv element.
|
|
9
|
+
// Spawn with shell:false against a resolved binary so the prompt cannot be
|
|
10
|
+
// interpreted by a shell (shell:true would let "& cmd" inject). If the
|
|
11
|
+
// binary resolves to a .cmd/.bat shim, resolveBinaryPath returns the name
|
|
12
|
+
// unchanged and we fall back to the shell (npx-style fallback launchers).
|
|
13
|
+
const binary = resolveBinaryPath(this.command);
|
|
14
|
+
const spawnOpts = getSpawnOptions(cwd, { shell: false });
|
|
9
15
|
if (Object.keys(this.spawnEnv).length > 0) spawnOpts.env = { ...spawnOpts.env, ...this.spawnEnv };
|
|
10
16
|
if (this.closeStdin) spawnOpts.stdio = ['ignore', 'pipe', 'pipe'];
|
|
11
|
-
const proc = spawn(
|
|
17
|
+
const proc = spawn(binary, args, spawnOpts);
|
|
12
18
|
console.log(`[${this.id}] Spawned PID ${proc.pid} closeStdin=${this.closeStdin}`);
|
|
13
19
|
|
|
14
20
|
if (config.onPid) { try { config.onPid(proc.pid); } catch (e) { console.error(`[${this.id}] onPid callback failed:`, e.message); } }
|
package/lib/claude-runner.js
CHANGED
|
@@ -4,12 +4,33 @@ const isWindows = process.platform === 'win32';
|
|
|
4
4
|
|
|
5
5
|
export function getSpawnOptions(cwd, additionalOptions = {}) {
|
|
6
6
|
const options = { cwd, ...additionalOptions };
|
|
7
|
-
|
|
7
|
+
// shell:true on Windows concatenates argv WITHOUT escaping, so a user prompt
|
|
8
|
+
// containing &, |, >, or backticks executes as a shell command (verified
|
|
9
|
+
// injection). Callers that pass untrusted args (the direct runner) must set
|
|
10
|
+
// shell:false and spawn a resolved binary. Default keeps the legacy shell
|
|
11
|
+
// behaviour only when a caller has not already chosen.
|
|
12
|
+
if (isWindows && options.shell === undefined) options.shell = true;
|
|
8
13
|
if (!options.env) options.env = { ...process.env };
|
|
9
14
|
delete options.env.CLAUDECODE;
|
|
10
15
|
return options;
|
|
11
16
|
}
|
|
12
17
|
|
|
18
|
+
// Resolve a bare command name to an absolute executable path so it can be
|
|
19
|
+
// spawned with shell:false (no argv concatenation, no injection surface).
|
|
20
|
+
// Returns the original command if resolution fails (Node will still PATH-resolve).
|
|
21
|
+
export function resolveBinaryPath(command) {
|
|
22
|
+
try {
|
|
23
|
+
const whichCmd = isWindows ? 'where' : 'which';
|
|
24
|
+
const r = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
|
|
25
|
+
if (r.status === 0) {
|
|
26
|
+
const first = (r.stdout || '').trim().split(/\r?\n/)[0].trim();
|
|
27
|
+
// A .cmd/.bat shim cannot be exec'd without a shell; leave it to the caller.
|
|
28
|
+
if (first && !/\.(cmd|bat)$/i.test(first)) return first;
|
|
29
|
+
}
|
|
30
|
+
} catch (_) {}
|
|
31
|
+
return command;
|
|
32
|
+
}
|
|
33
|
+
|
|
13
34
|
export function resolveCommand(command, npxPackage) {
|
|
14
35
|
const whichCmd = isWindows ? 'where' : 'which';
|
|
15
36
|
const check = spawnSync(whichCmd, [command], { encoding: 'utf-8', timeout: 3000 });
|
package/package.json
CHANGED
package/site/app/index.html
CHANGED
|
@@ -63,21 +63,28 @@
|
|
|
63
63
|
}
|
|
64
64
|
.skip-link:focus { left: 8px; top: 8px; outline: 2px solid #fff; outline-offset: 2px; }
|
|
65
65
|
|
|
66
|
-
/* status connection dot
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
66
|
+
/* status connection dot — a CSS-drawn disc (real UI affordance, not a text
|
|
67
|
+
glyph). The .status-dot-disc child is always present; the parent's state
|
|
68
|
+
class colours it, and only is-live pulses. */
|
|
69
|
+
.status-dot { display: inline-flex; align-items: center; gap: .4em; white-space: nowrap; }
|
|
70
|
+
.status-dot-disc {
|
|
71
|
+
flex: none; width: 8px; height: 8px; border-radius: 50%;
|
|
72
|
+
background: var(--muted, #8a8f98);
|
|
73
|
+
}
|
|
74
|
+
.status-dot.is-live .status-dot-disc,
|
|
75
|
+
.status-dot-live .status-dot-disc {
|
|
76
|
+
background: var(--accent, var(--agentgui-accent, #50c878));
|
|
72
77
|
animation: agentgui-pulse 2s infinite;
|
|
73
78
|
}
|
|
79
|
+
.status-dot.is-connecting .status-dot-disc { background: #d9a441; }
|
|
80
|
+
.status-dot.is-offline .status-dot-disc { background: #c0504d; animation: none; }
|
|
74
81
|
@keyframes agentgui-pulse {
|
|
75
82
|
0% { box-shadow: 0 0 0 0 rgba(80, 200, 120, .5); }
|
|
76
83
|
70% { box-shadow: 0 0 0 6px rgba(80, 200, 120, 0); }
|
|
77
84
|
100% { box-shadow: 0 0 0 0 rgba(80, 200, 120, 0); }
|
|
78
85
|
}
|
|
79
86
|
@media (prefers-reduced-motion: reduce) {
|
|
80
|
-
.status-dot-
|
|
87
|
+
.status-dot-disc { animation: none !important; }
|
|
81
88
|
}
|
|
82
89
|
|
|
83
90
|
/* resume banner */
|
package/site/app/js/app.js
CHANGED
|
@@ -263,21 +263,20 @@ function closeLiveStream() {
|
|
|
263
263
|
function view() {
|
|
264
264
|
const ok = state.health.status === 'ok';
|
|
265
265
|
const liveActive = state.tab === 'history' && state.live.connected && (Date.now() - state.live.lastEventTs < 30000);
|
|
266
|
-
const
|
|
266
|
+
const dotLabel = state.tab === 'history'
|
|
267
267
|
? (state.live.error
|
|
268
|
-
?
|
|
269
|
-
: (liveActive ? '
|
|
270
|
-
: (ok ? (state.health.ws === 'reconnecting' ? '
|
|
268
|
+
? state.live.error + (state.live.reconnects ? ' · ' + state.live.reconnects + ' reconnects' : '')
|
|
269
|
+
: (liveActive ? 'live · ' + state.live.eventCount : (state.live.connected ? 'live' : 'connecting…')))
|
|
270
|
+
: (ok ? (state.health.ws === 'reconnecting' ? 'ws reconnecting' : 'connected') : 'offline');
|
|
271
271
|
const dotLive = state.tab === 'history' ? (liveActive || state.live.connected) : ok;
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
(!dotLive && dotGlyph) ? h('span', { key: 'dg', 'aria-hidden': 'true' }, dotGlyph + ' ') : null,
|
|
272
|
+
// The status dot is drawn entirely by CSS (.status-dot::before) — a small
|
|
273
|
+
// colored disc, real product design, not a text glyph. State drives its colour
|
|
274
|
+
// via the modifier class; the label carries only words so AT reads "live", and
|
|
275
|
+
// there are no literal status-glyph characters in the DOM.
|
|
276
|
+
const dotState = state.live.error || (!dotLive) ? 'is-offline'
|
|
277
|
+
: (dotLive && state.tab !== 'history' && state.health.ws === 'reconnecting' ? 'is-connecting' : 'is-live');
|
|
278
|
+
const dot = h('span', { key: 'dot', class: 'status-dot ' + dotState + (dotLive ? ' status-dot-live' : ''), role: 'status', 'aria-live': 'polite' },
|
|
279
|
+
h('span', { key: 'dd', class: 'status-dot-disc', 'aria-hidden': 'true' }),
|
|
281
280
|
h('span', { key: 'dl' }, dotLabel));
|
|
282
281
|
|
|
283
282
|
const topbar = Topbar({
|
|
@@ -288,11 +287,27 @@ function view() {
|
|
|
288
287
|
onNav: (label) => navTo(label),
|
|
289
288
|
});
|
|
290
289
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
290
|
+
// The four flagship orchestration targets surface first, then the rest of the
|
|
291
|
+
// available agents, then unavailable ones — so the agents the GUI exists to
|
|
292
|
+
// drive are reachable without scanning a flat 17-item list. Ordering only;
|
|
293
|
+
// the DS Select renders a plain option list.
|
|
294
|
+
const PRIMARY_AGENTS = ['claude-code', 'opencode', 'kilo', 'agy'];
|
|
295
|
+
const agentRank = (a) => {
|
|
296
|
+
const primary = PRIMARY_AGENTS.indexOf(a.id);
|
|
297
|
+
const avail = a.available !== false;
|
|
298
|
+
if (primary !== -1 && avail) return primary; // 0..3 flagship + available
|
|
299
|
+
if (avail) return 10; // other available
|
|
300
|
+
if (a.npxInstallable) return 20; // installable via npx
|
|
301
|
+
return 30; // not installed
|
|
302
|
+
};
|
|
303
|
+
const agentOptions = state.agents
|
|
304
|
+
.map(a => ({ a, rank: agentRank(a) }))
|
|
305
|
+
.sort((x, y) => x.rank - y.rank || x.a.name.localeCompare(y.a.name))
|
|
306
|
+
.map(({ a }) => ({
|
|
307
|
+
value: a.id,
|
|
308
|
+
label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
|
|
309
|
+
disabled: a.available === false && !a.npxInstallable,
|
|
310
|
+
}));
|
|
296
311
|
const showModelPicker = state.tab === 'chat' && state.agentModels.length > 0;
|
|
297
312
|
|
|
298
313
|
const crumbRight = state.tab === 'chat'
|
|
@@ -316,7 +331,7 @@ function view() {
|
|
|
316
331
|
})
|
|
317
332
|
: null,
|
|
318
333
|
state.chat.busy
|
|
319
|
-
? Btn({ key: 'stop', onClick: cancelChat, children: '
|
|
334
|
+
? Btn({ key: 'stop', onClick: cancelChat, children: 'stop', title: 'Stop streaming' })
|
|
320
335
|
: Btn({ key: 'new', onClick: newChat, children: '+ new', title: 'Start new chat (clears history)' }),
|
|
321
336
|
dot,
|
|
322
337
|
)]
|
|
@@ -335,10 +350,10 @@ function view() {
|
|
|
335
350
|
const side = state.tab === 'history' ? historySide() : null;
|
|
336
351
|
|
|
337
352
|
const agentLabel = state.selectedAgent
|
|
338
|
-
? '
|
|
339
|
-
: '
|
|
353
|
+
? 'agent: ' + (agentById(state.selectedAgent)?.name || state.selectedAgent) + (state.selectedModel ? ' · ' + state.selectedModel : '')
|
|
354
|
+
: 'no agent';
|
|
340
355
|
const status = Status({
|
|
341
|
-
left: [state.backend || 'same-origin', ok ? '
|
|
356
|
+
left: [state.backend || 'same-origin', ok ? 'live' : 'offline'],
|
|
342
357
|
right: [agentLabel],
|
|
343
358
|
});
|
|
344
359
|
|
|
@@ -374,14 +389,14 @@ function toolSummary(block) {
|
|
|
374
389
|
arg = inp.command || inp.file_path || inp.path || inp.pattern || inp.query || inp.url || '';
|
|
375
390
|
if (!arg) { try { arg = JSON.stringify(inp).slice(0, 120); } catch {} }
|
|
376
391
|
}
|
|
377
|
-
return '
|
|
392
|
+
return 'tool: ' + name + (arg ? ' · ' + String(arg).slice(0, 120) : '');
|
|
378
393
|
}
|
|
379
394
|
|
|
380
395
|
function toolResultSummary(block) {
|
|
381
396
|
const c = block?.content ?? block?.output ?? block;
|
|
382
397
|
let s = typeof c === 'string' ? c : (() => { try { return JSON.stringify(c); } catch { return String(c); } })();
|
|
383
398
|
s = s.replace(/\s+/g, ' ').trim();
|
|
384
|
-
return (block?.is_error ? '
|
|
399
|
+
return (block?.is_error ? '[error] ' : '') + s.slice(0, 160);
|
|
385
400
|
}
|
|
386
401
|
|
|
387
402
|
function errText(e) {
|
|
@@ -419,15 +434,24 @@ function chatMain() {
|
|
|
419
434
|
value: state.chat.draft,
|
|
420
435
|
disabled: !canSend(),
|
|
421
436
|
placeholder,
|
|
422
|
-
|
|
437
|
+
// The DS textarea is controlled and reads its live DOM value on send, so we
|
|
438
|
+
// do NOT re-render on every keystroke (that fights the cursor and is wasteful).
|
|
439
|
+
// Re-render only when the draft crosses empty<->non-empty, since that is the
|
|
440
|
+
// only transition that changes the send button's disabled state.
|
|
441
|
+
onInput: (v) => {
|
|
442
|
+
const was = !!(state.chat.draft && state.chat.draft.trim());
|
|
443
|
+
const now = !!(v && v.trim());
|
|
444
|
+
state.chat.draft = v;
|
|
445
|
+
if (was !== now) render();
|
|
446
|
+
},
|
|
423
447
|
onSend: (v) => { state.chat.draft = v; sendChat(); },
|
|
424
448
|
});
|
|
425
449
|
|
|
426
450
|
const banners = [];
|
|
427
451
|
if (state.chat.resumeSid) {
|
|
428
452
|
banners.push(h('div', { key: 'rb', class: 'resume-banner', role: 'status' },
|
|
429
|
-
h('span', { key: 'rbtxt', class: 'lede' }, '
|
|
430
|
-
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: '
|
|
453
|
+
h('span', { key: 'rbtxt', class: 'lede' }, 'resuming session ' + state.chat.resumeSid.slice(0, 8) + '… via --resume'),
|
|
454
|
+
Btn({ key: 'rclr', onClick: () => { state.chat.resumeSid = null; state.chat.resumeNote = null; render(); }, children: 'clear' })));
|
|
431
455
|
if (state.chat.resumeNote) {
|
|
432
456
|
banners.push(Alert({ key: 'rnote', kind: 'info', title: 'Agent switched', children: state.chat.resumeNote }));
|
|
433
457
|
}
|
|
@@ -488,9 +512,9 @@ function cwdBanner() {
|
|
|
488
512
|
}
|
|
489
513
|
return h('div', { key: 'cwdb', class: 'cwd-bar', role: 'group', 'aria-label': 'Working directory' },
|
|
490
514
|
h('span', { key: 'cwdtxt', class: 'lede cwd-bar-text', title: state.chatCwd || 'server default working directory' },
|
|
491
|
-
state.chatCwd ? '
|
|
515
|
+
state.chatCwd ? 'cwd: ' + truncate(state.chatCwd, 28, 60) : 'cwd: server default'),
|
|
492
516
|
h('button', { key: 'cwdset', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.cwdEditing = true; state.cwdDraft = state.chatCwd || ''; render(); } }, state.chatCwd ? 'change' : 'set'),
|
|
493
|
-
state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, '
|
|
517
|
+
state.chatCwd ? h('button', { key: 'cwdclr', type: 'button', class: 'cwd-bar-btn', onClick: () => { state.chatCwd = ''; lsRemove('agentgui.cwd'); render(); } }, 'use default') : null);
|
|
494
518
|
}
|
|
495
519
|
|
|
496
520
|
function newChat() {
|
|
@@ -553,7 +577,7 @@ async function sendChat() {
|
|
|
553
577
|
})) {
|
|
554
578
|
if (ev.type === 'text') { cur.content += ev.text; render(); scrollChatToBottom(); }
|
|
555
579
|
else if (ev.type === 'tool') { cur.parts.push(toolSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
556
|
-
else if (ev.type === 'tool_result') { cur.parts.push('
|
|
580
|
+
else if (ev.type === 'tool_result') { cur.parts.push('-> ' + toolResultSummary(ev.block)); render(); scrollChatToBottom(); }
|
|
557
581
|
else if (ev.type === 'result') { /* terminal usage/summary block — already reflected via text */ }
|
|
558
582
|
else if (ev.type === 'error') { cur.error = errText(ev.error); render(); }
|
|
559
583
|
}
|
|
@@ -585,11 +609,10 @@ function historyMain() {
|
|
|
585
609
|
return [
|
|
586
610
|
reconnectAlert(),
|
|
587
611
|
PageHeader({
|
|
588
|
-
title: '
|
|
612
|
+
title: 'history',
|
|
589
613
|
lede: 'pick a session from the sidebar — events stream live from ccsniff /v1/history.',
|
|
590
614
|
}),
|
|
591
615
|
h('div', { key: 'histempty', class: 'history-empty', role: 'status' },
|
|
592
|
-
h('div', { key: 'ge', class: 'history-empty-glyph', 'aria-hidden': 'true' }, '§'),
|
|
593
616
|
h('p', { key: 'gt', class: 'history-empty-title' },
|
|
594
617
|
count ? 'Select a session to view its events' : 'No sessions yet'),
|
|
595
618
|
h('p', { key: 'gs', class: 'lede history-empty-sub' },
|
|
@@ -605,13 +628,13 @@ function historyMain() {
|
|
|
605
628
|
: state.selectedSid;
|
|
606
629
|
|
|
607
630
|
const head = PageHeader({
|
|
608
|
-
title:
|
|
631
|
+
title: truncate(projectLabel(sess?.title) || projectLabel(sess?.project) || state.selectedSid, 40, 80),
|
|
609
632
|
lede,
|
|
610
633
|
});
|
|
611
634
|
|
|
612
635
|
const actions = h('div', { key: 'acts', class: 'history-actions' },
|
|
613
|
-
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: '
|
|
614
|
-
Btn({ key: 'copy', onClick: copySid, children: copyToast || '
|
|
636
|
+
Btn({ key: 'resume', primary: true, onClick: () => resumeInChat(sess || { sid: state.selectedSid }), children: 'open in chat' }),
|
|
637
|
+
Btn({ key: 'copy', onClick: copySid, children: copyToast || 'copy sid' }),
|
|
615
638
|
);
|
|
616
639
|
|
|
617
640
|
if (state.events.length === 0) {
|
|
@@ -641,8 +664,8 @@ function historyMain() {
|
|
|
641
664
|
const key = e.i != null ? 'ev' + e.i : 'ev-' + (e.ts || 0) + '-' + (total - shown.length + i);
|
|
642
665
|
const role = e.role || '?';
|
|
643
666
|
const type = e.type || '?';
|
|
644
|
-
const tool = e.tool ? ' ·
|
|
645
|
-
const errMark = e.isError ? ' ·
|
|
667
|
+
const tool = e.tool ? ' · tool: ' + e.tool : '';
|
|
668
|
+
const errMark = e.isError ? ' · error' : '';
|
|
646
669
|
const raw = e.text || '';
|
|
647
670
|
const text = raw.replace(/\s+/g, ' ').trim();
|
|
648
671
|
const expanded = state.expandedEvents.has(key);
|
|
@@ -664,9 +687,9 @@ let copyToast = null;
|
|
|
664
687
|
function copySid() {
|
|
665
688
|
const sid = state.selectedSid;
|
|
666
689
|
if (!sid) return;
|
|
667
|
-
const done = () => { copyToast = '
|
|
690
|
+
const done = () => { copyToast = 'copied'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); };
|
|
668
691
|
if (navigator.clipboard?.writeText) {
|
|
669
|
-
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = '
|
|
692
|
+
navigator.clipboard.writeText(sid).then(done).catch(() => { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); });
|
|
670
693
|
} else {
|
|
671
694
|
// Fallback for insecure (http) origins where navigator.clipboard is absent.
|
|
672
695
|
try {
|
|
@@ -674,7 +697,7 @@ function copySid() {
|
|
|
674
697
|
ta.value = sid; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
675
698
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove();
|
|
676
699
|
done();
|
|
677
|
-
} catch { copyToast = '
|
|
700
|
+
} catch { copyToast = 'copy failed'; render(); setTimeout(() => { copyToast = null; render(); }, 1500); }
|
|
678
701
|
}
|
|
679
702
|
}
|
|
680
703
|
|
|
@@ -746,7 +769,7 @@ function historySide() {
|
|
|
746
769
|
Row({
|
|
747
770
|
key: 'sess' + s.sid,
|
|
748
771
|
rank: String(i + 1).padStart(3, '0'),
|
|
749
|
-
title: (s.isSubagent ? '
|
|
772
|
+
title: (s.isSubagent ? '- ' : '') + (projectLabel(s.title) || projectLabel(s.project) || s.sid),
|
|
750
773
|
sub: fmtRelTime(s.last) + ' · ' + (s.events || 0) + ' ev · ' + (s.tools || 0) + ' tools' + (s.errors ? ' · ' + s.errors + ' err' : ''),
|
|
751
774
|
rail: s.errors ? 'flame' : (s.isSubagent ? 'purple' : 'green'),
|
|
752
775
|
active: s.sid === state.selectedSid,
|
|
@@ -761,13 +784,14 @@ function historySide() {
|
|
|
761
784
|
running.length
|
|
762
785
|
? Panel({
|
|
763
786
|
key: 'runningPanel',
|
|
764
|
-
title: '
|
|
787
|
+
title: 'running · ' + running.length,
|
|
765
788
|
children: running.map((r, i) => {
|
|
766
789
|
const agentName = agentById(r.agentId)?.name || r.agentId || 'agent';
|
|
767
790
|
const elapsed = r.startedAt ? Math.round((Date.now() - r.startedAt) / 1000) : 0;
|
|
768
791
|
return h('div', { key: 'run' + r.sessionId, class: 'resume-banner', role: 'group' },
|
|
769
|
-
h('span', {
|
|
770
|
-
|
|
792
|
+
h('span', { key: 'rd', class: 'status-dot-disc status-dot-live', 'aria-hidden': 'true' }),
|
|
793
|
+
h('span', { class: 'lede' }, agentName + (r.model ? ' · ' + r.model : '') + ' · ' + elapsed + 's' + (r.cwd ? ' · ' + r.cwd.split(/[/\\]/).filter(Boolean).slice(-1)[0] : '')),
|
|
794
|
+
Btn({ key: 'stop' + r.sessionId, onClick: () => stopActiveChat(r.sessionId), children: 'stop' }));
|
|
771
795
|
}),
|
|
772
796
|
})
|
|
773
797
|
: null,
|
|
@@ -796,7 +820,7 @@ function historySide() {
|
|
|
796
820
|
? h('p', { key: 'min2', class: 'lede empty-state' }, 'type at least 2 characters to search')
|
|
797
821
|
: null,
|
|
798
822
|
state.searchQ
|
|
799
|
-
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: '
|
|
823
|
+
? Btn({ key: 'clearq', onClick: () => { state.searchQ = ''; state.searchHits = null; state.searchBusy = false; render(); }, children: 'clear search' })
|
|
800
824
|
: null,
|
|
801
825
|
!searching && projects.length > 1
|
|
802
826
|
? h('div', { key: 'projfilter', class: 'pill-row', role: 'group', 'aria-label': 'Filter sessions by project' },
|
|
@@ -810,10 +834,10 @@ function historySide() {
|
|
|
810
834
|
'show subagents (' + subagentCount + ')')
|
|
811
835
|
: null,
|
|
812
836
|
state.historyError
|
|
813
|
-
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' },
|
|
837
|
+
? h('p', { key: 'err', class: 'lede field-error', role: 'alert' }, state.historyError)
|
|
814
838
|
: (rows.length ? h('div', { key: 'rows' }, ...rows) : h('p', { key: 'empty', class: 'lede empty-state' }, 'no sessions yet')),
|
|
815
839
|
!searching && truncatedBy > 0
|
|
816
|
-
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: '
|
|
840
|
+
? Btn({ key: 'more', onClick: () => { state.sessionsLimit += 60; render(); }, children: 'show '+Math.min(60, truncatedBy)+' more ('+truncatedBy+' hidden)' })
|
|
817
841
|
: null,
|
|
818
842
|
],
|
|
819
843
|
}),
|
|
@@ -842,9 +866,8 @@ async function saveBackend() {
|
|
|
842
866
|
function healthSummary() {
|
|
843
867
|
const hh = state.health || {};
|
|
844
868
|
const ok = hh.status === 'ok';
|
|
845
|
-
const dot = ok ? '●' : (hh.status === 'unknown' ? '◌' : '○');
|
|
846
869
|
const bits = [];
|
|
847
|
-
bits.push(
|
|
870
|
+
bits.push(hh.status || 'unknown');
|
|
848
871
|
if (hh.version) bits.push('v' + hh.version);
|
|
849
872
|
if (typeof hh.agents === 'number') bits.push(hh.agents + ' agents');
|
|
850
873
|
if (typeof hh.activeExecutions === 'number') bits.push(hh.activeExecutions + ' active');
|
|
@@ -858,7 +881,7 @@ function settingsMain() {
|
|
|
858
881
|
const isValid = isValidUrl(state.backendDraft);
|
|
859
882
|
return [
|
|
860
883
|
PageHeader({
|
|
861
|
-
title: '
|
|
884
|
+
title: 'settings',
|
|
862
885
|
lede: 'point agentgui at any backend. blank = same-origin (ccsniff in-process). ?backend=… or the field below persists via localStorage.',
|
|
863
886
|
}),
|
|
864
887
|
h('div', { key: 'settings-grid', class: 'settings-grid' }, [
|
|
@@ -878,10 +901,10 @@ function settingsMain() {
|
|
|
878
901
|
title: isValid ? 'Enter a valid URL or leave blank for same-origin' : 'Invalid URL format',
|
|
879
902
|
onInput: (v) => { state.backendDraft = v; render(); },
|
|
880
903
|
}),
|
|
881
|
-
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, '
|
|
882
|
-
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '
|
|
883
|
-
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, '
|
|
884
|
-
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, '
|
|
904
|
+
!isValid ? h('p', { key: 'err', id: 'backend-url-error', class: 'lede field-error', role: 'alert' }, 'Invalid URL format') : null,
|
|
905
|
+
state.backendStatus === 'connecting' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connecting…') : null,
|
|
906
|
+
state.backendStatus === 'ok' ? h('p', { key: 'bst', class: 'lede', role: 'status' }, 'connected') : null,
|
|
907
|
+
state.backendStatus === 'failed' ? h('p', { key: 'bst', class: 'lede field-error', role: 'alert' }, 'connection failed — check the URL') : null,
|
|
885
908
|
healthSummary(),
|
|
886
909
|
Btn({
|
|
887
910
|
key: 'savebtn',
|
|
@@ -518,14 +518,43 @@
|
|
|
518
518
|
|
|
519
519
|
.ds-247420 .app-topbar {
|
|
520
520
|
position: sticky; top: 0; z-index: var(--z-header);
|
|
521
|
-
display:
|
|
521
|
+
display: flex; flex-wrap: wrap;
|
|
522
522
|
align-items: center; gap: var(--space-4);
|
|
523
523
|
min-height: var(--app-topbar-h);
|
|
524
524
|
padding: 10px var(--pad-x);
|
|
525
525
|
background: color-mix(in oklab, var(--bg) 88%, transparent);
|
|
526
526
|
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
|
527
527
|
}
|
|
528
|
-
|
|
528
|
+
|
|
529
|
+
/* Merged chrome: when AppShell gets both a topbar and a crumb it wraps them
|
|
530
|
+
in .app-chrome and they share ONE sticky band instead of stacking as two
|
|
531
|
+
bars. The breadcrumb provides the left identity (it already begins with the
|
|
532
|
+
brand), so the topbar's standalone brand is hidden to avoid showing the name
|
|
533
|
+
twice; nav and the crumb's right slot sit together on the right. */
|
|
534
|
+
.ds-247420 .app-chrome {
|
|
535
|
+
position: sticky; top: 0; z-index: var(--z-header);
|
|
536
|
+
display: flex; flex-wrap: wrap; align-items: center;
|
|
537
|
+
gap: var(--space-2) var(--space-4);
|
|
538
|
+
padding: 8px var(--pad-x);
|
|
539
|
+
background: color-mix(in oklab, var(--bg) 88%, transparent);
|
|
540
|
+
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
|
541
|
+
}
|
|
542
|
+
.ds-247420 .app-chrome > .app-topbar,
|
|
543
|
+
.ds-247420 .app-chrome > .app-crumb {
|
|
544
|
+
position: static; background: none; backdrop-filter: none;
|
|
545
|
+
-webkit-backdrop-filter: none; padding: 0; min-height: 0;
|
|
546
|
+
flex: 1 1 auto;
|
|
547
|
+
}
|
|
548
|
+
.ds-247420 .app-chrome > .app-crumb { order: 1; flex: 1 1 auto; }
|
|
549
|
+
.ds-247420 .app-chrome > .app-topbar { order: 2; flex: 0 0 auto; }
|
|
550
|
+
.ds-247420 .app-chrome > .app-topbar > .brand { display: none; }
|
|
551
|
+
.ds-247420 .app-chrome > .app-topbar > nav { margin-left: 0; }
|
|
552
|
+
.ds-247420 .app-chrome > .app-crumb > .crumb-right { margin-left: auto; }
|
|
553
|
+
.ds-247420 .app-topbar > .brand { flex: 0 0 auto; }
|
|
554
|
+
.ds-247420 .app-topbar > .app-search { flex: 1 1 auto; }
|
|
555
|
+
.ds-247420 .app-topbar > nav { margin-left: auto; }
|
|
556
|
+
.ds-247420 .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); flex-wrap: wrap; }
|
|
557
|
+
.ds-247420 .app-topbar nav a { flex: 0 0 auto; }
|
|
529
558
|
.ds-247420 .app-topbar nav a {
|
|
530
559
|
color: var(--fg-2);
|
|
531
560
|
padding: 12px 14px;
|
|
@@ -1083,25 +1112,21 @@
|
|
|
1083
1112
|
/* App Layout: single-column + drawer is handled once in the ≤900px block;
|
|
1084
1113
|
no need to re-declare grid-template-columns here (was a redundant !important). */
|
|
1085
1114
|
|
|
1086
|
-
/* Topbar Navigation
|
|
1115
|
+
/* Topbar Navigation: stack the nav onto its own full-width row below the
|
|
1116
|
+
brand and let it scroll horizontally. The brand stays on the first row;
|
|
1117
|
+
nav wraps to a second row at 100% width (flex-wrap on .app-topbar). */
|
|
1087
1118
|
.ds-247420 .app-topbar {
|
|
1088
|
-
grid-template-columns: 1fr auto;
|
|
1089
1119
|
gap: var(--space-2); padding: 12px var(--space-3);
|
|
1090
1120
|
}
|
|
1091
|
-
.ds-247420 .app-topbar nav
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1095
|
-
/* Keep primary nav reachable on small screens (apps without a sidebar have
|
|
1096
|
-
no other entry point). Compact it and let it scroll horizontally rather
|
|
1097
|
-
than hiding it entirely. */
|
|
1098
|
-
.ds-247420 .app-topbar nav {
|
|
1099
|
-
display: flex; gap: 2px;
|
|
1121
|
+
.ds-247420 .app-topbar > nav {
|
|
1122
|
+
flex: 1 1 100%; margin-left: 0; order: 3;
|
|
1123
|
+
display: flex; gap: 2px; flex-wrap: nowrap;
|
|
1100
1124
|
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
|
1101
|
-
max-width: 60vw;
|
|
1102
1125
|
}
|
|
1103
1126
|
.ds-247420 .app-topbar nav::-webkit-scrollbar { display: none; }
|
|
1104
|
-
.ds-247420 .app-topbar nav a {
|
|
1127
|
+
.ds-247420 .app-topbar nav a {
|
|
1128
|
+
flex: 0 0 auto; padding: 10px 12px; min-height: 44px; white-space: nowrap;
|
|
1129
|
+
}
|
|
1105
1130
|
.ds-247420 .brand { font-size: var(--fs-tiny); font-weight: 600; }
|
|
1106
1131
|
|
|
1107
1132
|
/* Search */
|