Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/main/__tests__/agent-shells.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,38 @@ describe('AgentShells · 收工', () => {
expect(second.info.id).not.toBe(first.info.id)
})
})

describe('AgentShells · 界面旁观', () => {
it('★ peek 不消费模型的缓冲 —— 界面看过一眼,BashOutput 照样读得到', async () => {
const shells = new AgentShells()
const { info, process } = adopt(shells)
process.emit('stdout', 'ready\n')
process.emit('stderr', 'warn\n')
await flush()
expect(shells.peek(info.id)?.output).toBe('ready\nwarn\n')
expect(shells.read(info.id).stdout).toBe('ready\n')
// 模型读走之后,界面的尾巴不跟着清空
expect(shells.peek(info.id)?.output).toBe('ready\nwarn\n')
})

it('peek 认不出的 id 返回 undefined,不 throw —— 界面上那条可能刚被淘汰', () => {
expect(new AgentShells().peek('bash_404')).toBeUndefined()
})

it('开、杀、退出各通知一次;输出增长不通知', async () => {
const shells = new AgentShells()
const listener = vi.fn()
shells.onChange(listener)
const first = adopt(shells)
expect(listener).toHaveBeenCalledTimes(1)
first.process.emit('stdout', 'log line\n')
await flush()
expect(listener).toHaveBeenCalledTimes(1)
shells.kill(first.info.id)
expect(listener).toHaveBeenCalledTimes(2)
const second = adopt(shells, { callId: 'call_2' })
second.process.finish(0)
await flush()
expect(listener).toHaveBeenCalledTimes(4)
})
})
56 changes: 55 additions & 1 deletion src/main/agent-shells.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ interface BackgroundEntry {
info: BackgroundShellInfo
stdout: Stream
stderr: Stream
/**
* 给界面看的输出尾巴(stdout 与 stderr 按到达顺序交错)。
*
* ★ 与上面两条缓冲**分开记**:那两条是「读走即清空」,专为 `BashOutput` 的
* 「两次读之间不重不漏」服务。界面若也去读它们,就会把模型还没读到的输出吃掉 ——
* 模型下一次 `BashOutput` 什么都拿不到,而它会据此断定「服务没有任何输出」。
*/
tail: string
process: AdoptableProcess
/** 环境租约。★ 必须活到进程结束——SSH 连接空闲 60s 就会被回收,见 manager.ts。 */
release: () => void
Expand All @@ -69,6 +77,9 @@ function append(stream: Stream, chunk: string): void {
stream.dropped = true
}

/** 尾巴只留最后这么多字符。界面上只看最后几十行,不需要更多。 */
const TAIL_CHARS = 8 * 1024

/** 读走并清空。`filter` 是按**行**过滤的正则,调用方已经筛过 ReDoS。 */
function take(stream: Stream, filter: RegExp | undefined): string {
const text = stream.buffer
Expand All @@ -87,6 +98,30 @@ export class AgentShells {
* 而两边都不会报错。
*/
private sequence = 0
private readonly listeners = new Set<() => void>()

/**
* 后台列表变了(新开、退出、被杀、被淘汰)。返回退订函数。
*
* ★ 只说「变了」,不带增量:监听方(IPC 广播)每次发全量,丢过一次通知也能自己收敛。
* 输出增长**不**算变化 —— 一个刷日志的 dev server 每秒几十次,全量广播会被它淹没。
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}

private changed(): void {
for (const listener of this.listeners) {
try {
listener()
} catch {
// 一个坏掉的监听方不能挡住进程状态本身的推进
}
}
}

// ─────────────────────────── 前台 ───────────────────────────

Expand Down Expand Up @@ -160,6 +195,7 @@ export class AgentShells {
},
stdout: emptyStream(),
stderr: emptyStream(),
tail: '',
process: req.process,
release: req.release
}
Expand All @@ -175,7 +211,11 @@ export class AgentShells {
代价:一条序列恰好被切在两个 data 事件之间时剥不干净(极少见,
残留是几个可见字符,不会影响后续行)。
*/
source.on('data', (chunk: string) => append(stream, stripAnsi(chunk)))
source.on('data', (chunk: string) => {
const text = stripAnsi(chunk)
append(stream, text)
entry.tail = (entry.tail + text).slice(-TAIL_CHARS)
})
// 进程被杀时管道会抛 EPIPE/ECONNRESET。它不是一个要上报的错误,
// 而没有这个监听器的话 Node 会把它升级成 uncaughtException 打死主进程。
source.on('error', () => {})
Expand All @@ -195,8 +235,10 @@ export class AgentShells {
}
entry.info.endedAt = Date.now()
this.evict()
this.changed()
})
this.evict()
this.changed()
return { ...entry.info }
}

Expand Down Expand Up @@ -229,6 +271,7 @@ export class AgentShells {
entry.info.status = 'killed'
entry.info.endedAt = Date.now()
entry.process.kill()
this.changed()
}
return { ...entry.info }
}
Expand All @@ -237,6 +280,16 @@ export class AgentShells {
return [...this.background.values()].map((entry) => ({ ...entry.info }))
}

/**
* 界面看一眼输出尾巴。**不消费**模型那两条缓冲(理由见 `BackgroundEntry.tail`)。
* 找不到时返回 `undefined` 而不是 throw:界面上那条可能刚好被淘汰,这不是错误。
*/
peek(id: string): { info: BackgroundShellInfo; output: string } | undefined {
const entry = this.background.get(id)
if (entry === undefined) return undefined
return { info: { ...entry.info }, output: entry.tail }
}

/**
* 应用退出 / 测试收尾:杀光所有后台进程。
*
Expand All @@ -254,6 +307,7 @@ export class AgentShells {
}
this.background.clear()
this.foreground.clear()
this.changed()
}

/** 只淘汰**已结束**的,从最早结束的那条开始。还在跑的一律不动。 */
Expand Down
13 changes: 11 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,15 +292,24 @@ function createMainWindow(
/*
★ **跟着用户的主题偏好走,不能写死。** 这个色是窗口在渲染层画出第一帧之前
露出来的底色 —— 写死深色的话,浅色用户看到的是「深色空块 → 界面出来 → 啪一下
变浅」。两个值取自 theme.css 里的 `--color-app`(深 #2a2d2b / 浅 #f2eee6),
变浅」。两个值取自 theme.css 里的 `--color-app`(深 #252527 / 浅 #ececf0),
改那边记得同步这里。

★ **不能用 `store.getSettings()`。** 建窗现在排在启动迁移闸门之前,而闸门
存在的意义正是「数据库还没打开」—— 在这里读库会先把库以内存兜底方式打开,
随后真正的 `openDatabase()` 直接抛错,启动死在那里。
`peekThemePreference()` 自己开一个只读连接,不碰那个句柄。
*/
backgroundColor: resolveTheme(peekThemePreference(join(resolveDataRoot(), DB_FILENAME))) === 'light' ? '#f2eee6' : '#2a2d2b',
backgroundColor: resolveTheme(peekThemePreference(join(resolveDataRoot(), DB_FILENAME))) === 'light' ? '#ececf0' : '#252527',
/*
★ 苹果风格(Liquid Glass):macOS 上窗口本身是毛玻璃,桌面壁纸透过来 ——
渲染层在 `data-vibrancy` 下把窗口底和两块主面板改成半透明(见 theme.css 末尾)。
vibrancy 只有在背景色全透明时才看得见,所以这里把上面那个底色覆盖掉;
首帧不会闪,因为系统材质本身就跟随深浅外观。Windows/Linux 保持实色。
*/
...(process.platform === 'darwin'
? { vibrancy: 'under-window' as const, visualEffectState: 'active' as const, backgroundColor: '#00000000' }
: {}),
// Windows/Linux 的任务栏与窗口图标(macOS 忽略,那边走下面的 app.dock.setIcon)
icon: nativeImage.createFromPath(appIconPath),
// macOS:红绿灯嵌进侧边栏(方案 §8)。Windows/Linux:只去掉系统标题栏,
Expand Down
7 changes: 7 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
store
} from '../state/store'
import { windows, type WindowContext } from '../window/registry'
import { agentShells } from '../agent-shells'
import { refreshScheduler, runScheduledTaskNow } from '../scheduled/scheduler'
import { scheduledWrites, setScheduledChangeListener } from '../scheduled/bridge'
import { setSchedulerRefresh } from '../scheduled/refresh'
Expand Down Expand Up @@ -604,6 +605,10 @@ const handlers: HandlerMap = {
// ── Agent 的 shell:只停这一条命令,run 继续 ──
'shell:stopToolCall': (req, ctx) => stopToolCall(req, ctx),
'shell:backgroundToolCall': (req, ctx) => backgroundToolCall(req, ctx),
// ── 后台 shell:右上角活动面板。按 id 操作,与模型的 BashOutput / KillShell 同一张表 ──
'shell:listBackground': ({ workspaceId }) => agentShells.list().filter((shell) => shell.workspaceId === workspaceId),
'shell:peekBackground': ({ id }) => agentShells.peek(id) ?? null,
'shell:killBackground': ({ id }) => agentShells.peek(id) === undefined ? null : agentShells.kill(id),
'agent:backgroundSubagent': (req, ctx) => backgroundSubagent(req, ctx),
// ── 步骤 8:终端 ──
'terminal:create': (req, ctx) => terminalHost.create(req, ctx.sender),
Expand Down Expand Up @@ -1078,6 +1083,8 @@ export function registerIpc(): void {
`windows.emitToAll` —— 那个模块拖着 electron,所以由这一侧装进去。
*/
setScheduledChangeListener((change) => windows.emitToAll('scheduled:changed', change))
// 同一种接线:`agent-shells.ts` 不认识窗口,广播由这一侧装进去
agentShells.onChange(() => windows.emitToAll('shell:backgroundChanged', { shells: agentShells.list() }))

/*
导入服务的接线,和上面 `setMcpChangeListener` 是同一种:
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/index.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN" data-accent="green">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/src/components/ProviderModelMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const PANEL = cn(
)

const ITEM = cn(
'flex w-full cursor-default select-none items-center gap-2.5 rounded-[7px] px-2.5 py-[7px]',
'flex w-full cursor-default select-none items-center gap-2.5 rounded-[8px] px-2.5 py-[7px]',
'text-left text-[13px] text-fg outline-none transition-colors',
'data-[highlighted]:bg-tint-strong data-[disabled]:opacity-40'
)
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 17 additions & 15 deletions src/renderer/src/components/arc/button/button.module.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 var(--space-4); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.button { position: relative; display: inline-flex; min-height: var(--control-height-md); align-items: center; justify-content: center; gap: var(--space-2); border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 var(--space-4); font: inherit; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }
.button:disabled { cursor: not-allowed; opacity: .52; }
.button[aria-busy="true"] { cursor: progress; }
/* Anchors of floating layers never scale: Radix opens on pointerdown and would measure a shrunken trigger. */
Expand All @@ -10,27 +10,29 @@
.labelContent { display: inline-flex; flex: none; align-items: center; }
.labelPhase { display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); white-space: nowrap; }
.labelPhase svg { flex: none; }
.primary { color: var(--background); background: var(--foreground); border-color: var(--foreground); }
.secondary { color: var(--foreground); background: var(--surface); border-color: var(--border); }
/* macOS 默认按钮:强调色实底 + 顶部一道极淡的高光渐变 */
.primary { color: var(--accent-foreground); background: linear-gradient(to bottom, rgb(255 255 255 / .1), rgb(255 255 255 / 0)) var(--accent); border-color: transparent; box-shadow: 0 0 0 .5px color-mix(in srgb, var(--accent), black 25%), 0 1px 1.5px rgb(0 0 0 / .12); }
/* macOS 普通按钮:白底(深色下是一层亮灰) + 发丝收边 + 一点投影,没有实线描边 */
.secondary { color: var(--foreground); background: var(--surface); border-color: var(--border-strong); }

.ghost { color: var(--text-secondary); background: transparent; border-color: transparent; }
.danger { color: var(--danger); background: var(--surface); border-color: var(--border); }
.danger { color: var(--danger); background: var(--surface); border-color: var(--border-strong); }
/* A disabled outlined button keeps its shape and fades only its label, so it still reads as a button that is off, not as plain text. */
.secondary:disabled, .danger:disabled { opacity: 1; color: var(--text-muted); background: var(--surface-muted); border-color: var(--border); }
.danger:disabled { color: color-mix(in oklab, var(--danger) 55%, var(--surface-muted)); }
.secondary:disabled, .danger:disabled { opacity: .55; color: var(--text-muted); }
.danger:disabled { color: color-mix(in oklab, var(--danger) 55%, var(--surface)); }
.ghost:disabled { opacity: 1; color: var(--text-muted); }
@media (hover: hover) and (pointer: fine) {
.primary:hover:not(:disabled) { opacity: .91; box-shadow: var(--shadow-resting); }
.secondary:hover:not(:disabled) { background: var(--surface-muted); box-shadow: var(--shadow-resting); }
.primary:hover:not(:disabled) { background-color: color-mix(in srgb, var(--accent), white 8%); }
.secondary:hover:not(:disabled), .danger:hover:not(:disabled) { background-color: color-mix(in srgb, var(--surface), var(--foreground) 4%); }
.ghost:hover:not(:disabled) { color: var(--foreground); background: var(--surface-muted); }
.danger:hover:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); }
}
/* Color feedback on press, so touch and menu triggers (which never scale) still answer the finger. */
.primary:active:not(:disabled) { opacity: .84; }
.secondary:active:not(:disabled), .ghost:active:not(:disabled) { color: var(--foreground); background: var(--surface-muted); }
.danger:active:not(:disabled) { border-color: var(--danger); background: var(--surface-muted); }
.sm { min-height: var(--control-height-sm); padding-inline: var(--space-3); font-size: var(--text-sm); }
.md { min-height: var(--control-height-md); }
.lg { min-height: var(--control-height-lg); padding-inline: var(--space-5); font-size: var(--text-sm); }
.primary:active:not(:disabled) { background-color: color-mix(in srgb, var(--accent), black 14%); }
.secondary:active:not(:disabled), .danger:active:not(:disabled) { background-color: color-mix(in srgb, var(--surface), var(--foreground) 10%); }
.ghost:active:not(:disabled) { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 10%, transparent); }
.sm { min-height: var(--control-height-sm); padding-inline: 12px; font-size: 12px; }
.md { min-height: var(--control-height-md); padding-inline: 14px; font-size: 13px; }
.lg { min-height: var(--control-height-lg); padding-inline: var(--space-5); font-size: 13px; }
.loader { position: absolute; left: 50%; top: 50%; display: grid; width: var(--space-4); height: var(--space-4); margin: calc(var(--space-4) / -2); }
.spinner { border: 1.5px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
.loadingLabel { opacity: 0; }
Expand Down
Loading
Loading