docs: Heicode 愿景范式、瀑布/敏捷子 Agnet 角色与规模
- 新增 docs/vision-heicode-full-stack-agentic-dev.md(完整范式与 W1-W9 / A1-A8 角色) - 新增根 README.md 与 .gitignore(排除 node_modules、target 等) Made-with: Cursor
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build / cache
|
||||
**/target/
|
||||
dist/
|
||||
build/
|
||||
.turbo/
|
||||
.next/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS / IDE
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Go
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
@@ -0,0 +1,32 @@
|
||||
# Heicode
|
||||
|
||||
Heicode 是一个**面向团队的智能体驱动软件交付**工作区:把 **改造后的大模型网关(New API)**、**终端与桌面上的智能体编程客户端(cc-haha)**,以及 **Agnet 平台上的子智能体团队** 串成一条从想法到上线、再到运维迭代的闭环。
|
||||
|
||||
## 这个仓库里有什么
|
||||
|
||||
| 目录 | 作用 |
|
||||
|------|------|
|
||||
| `new-api/` | 基于 New API 生态的 **网关与运营能力**(模型渠道、计费、鉴权、多协议中继等),经深度改造后作为用户登录后看到的 **后台与 API 能力层**。 |
|
||||
| `cc-haha/` | **CLI / 本地服务 / Desktop(Tauri)** 相关代码,提供类 **Claude Code** 的编程与协作体验,并与 New API 对齐登录与接口。 |
|
||||
| `_all-in-one-upload/` | 历史或打包上传相关目录(按需维护)。 |
|
||||
|
||||
## 产品在解决什么问题
|
||||
|
||||
- **自动化开发范式**:不止「写代码」,而是覆盖需求、实现、测试、**多云部署**、运维与迭代的 **标准化团队模式**(瀑布 / 敏捷模板 + 子智能体规模可配置)。
|
||||
- **平台整合**:用户从官网进入 → 使用改造后的 **New API** 控制台 → 下载 **CLI** 与 **Desktop** → 与 **Agnet** 上已部署的 **子智能体团队** 协同;子智能体可具备 Git 与云资源(GCP / AWS / Azure)的**策略化**自动化能力。
|
||||
- **可审计交付**:多智能体协作记录、产品文档、开发日志等与版本和发布关联。
|
||||
|
||||
## 详细愿景与范式说明
|
||||
|
||||
完整的产品叙事、三问回答、架构分层、用户旅程、**瀑布(5~9)与敏捷(3~8)子 Agnet 角色清单**、风险与路线图见:
|
||||
|
||||
**[docs/vision-heicode-full-stack-agentic-dev.md](./docs/vision-heicode-full-stack-agentic-dev.md)**
|
||||
|
||||
## 相关外部参考(概念)
|
||||
|
||||
- [oh-my-claudecode](https://ohmyclaudecode.com/) — 高效使用 Claude Code 类工具的实践与脚手架方向。
|
||||
- Agnet 平台文档(部署与编排由平台侧提供)— 集成时以平台当前 API 与权限模型为准。
|
||||
|
||||
## 许可证
|
||||
|
||||
各子项目可能沿用不同开源许可证(例如 `new-api` 侧常见为 AGPLv3),请以各子目录内 `LICENSE` 为准。
|
||||
@@ -0,0 +1,87 @@
|
||||
# Claude Code IM Adapters
|
||||
|
||||
当前目录只放 IM Adapter 运行时代码。
|
||||
|
||||
用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
|
||||
|
||||
- `docs/im/index.md`
|
||||
- `docs/im/telegram.md`
|
||||
- `docs/im/feishu.md`
|
||||
|
||||
## 当前方案摘要
|
||||
|
||||
当前真实链路是:
|
||||
|
||||
```text
|
||||
Desktop Webapp Settings
|
||||
-> /api/adapters
|
||||
-> ~/.claude/adapters.json
|
||||
-> adapters/<platform>/index.ts
|
||||
-> /api/sessions + /ws/:sessionId
|
||||
-> Claude Code session
|
||||
```
|
||||
|
||||
注意两点:
|
||||
|
||||
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
|
||||
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu`
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd adapters
|
||||
bun install
|
||||
bun run telegram
|
||||
# 或
|
||||
bun run feishu
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
cd adapters
|
||||
bun test
|
||||
bun test common/
|
||||
bun test telegram/
|
||||
bun test feishu/
|
||||
```
|
||||
|
||||
### 目录结构
|
||||
|
||||
```text
|
||||
adapters/
|
||||
├── common/
|
||||
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
|
||||
├── telegram/
|
||||
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
|
||||
├── feishu/
|
||||
│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
|
||||
│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 附件收发
|
||||
|
||||
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
|
||||
|
||||
**入站(用户 → Claude):**
|
||||
|
||||
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
|
||||
- Telegram: photo、document、video、audio、voice
|
||||
|
||||
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
|
||||
|
||||
**出站(Claude → 用户):**
|
||||
|
||||
Agent 流式文本里的 markdown 图片引用 `` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
|
||||
|
||||
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
|
||||
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
|
||||
|
||||
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
|
||||
|
||||
设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
|
||||
"productName": "HeiCode",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.heicode.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "bun run build:sidecars && bun run dev",
|
||||
"beforeBuildCommand": "bun run build && bun run build:sidecars"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HeiCode",
|
||||
"width": 1440,
|
||||
"height": 960,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"acceptFirstMouse": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"dangerousDisableAssetCspModification": ["style-src"],
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
|
||||
"endpoints": [],
|
||||
"windows": {
|
||||
"installMode": "passive"
|
||||
}
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installerHooks": "windows-installer-hooks.nsh"
|
||||
}
|
||||
},
|
||||
"externalBin": [
|
||||
"binaries/claude-sidecar"
|
||||
],
|
||||
"resources": [],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"dmg": {
|
||||
"appPosition": {
|
||||
"x": 180,
|
||||
"y": 170
|
||||
},
|
||||
"applicationFolderPosition": {
|
||||
"x": 480,
|
||||
"y": 170
|
||||
},
|
||||
"windowSize": {
|
||||
"width": 660,
|
||||
"height": 400
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from './client'
|
||||
import type { SkillMeta, SkillDetail } from '../types/skill'
|
||||
|
||||
export const skillsApi = {
|
||||
list: (cwd?: string) => {
|
||||
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
|
||||
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 })
|
||||
},
|
||||
|
||||
detail: (source: string, name: string, cwd?: string) => {
|
||||
const query = new URLSearchParams({
|
||||
source,
|
||||
name,
|
||||
})
|
||||
if (cwd) query.set('cwd', cwd)
|
||||
|
||||
return api.get<{ detail: SkillDetail }>(
|
||||
`/api/skills/detail?${query.toString()}`,
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
}))
|
||||
|
||||
import { wsManager } from './websocket'
|
||||
|
||||
type SocketHandler = (() => void) | ((event: { data: string }) => void)
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
static instances: FakeWebSocket[] = []
|
||||
|
||||
readonly url: string
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
onopen: SocketHandler | null = null
|
||||
onmessage: SocketHandler | null = null
|
||||
onclose: SocketHandler | null = null
|
||||
onerror: SocketHandler | null = null
|
||||
sent: string[] = []
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url
|
||||
FakeWebSocket.instances.push(this)
|
||||
}
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(data)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
;(this.onclose as (() => void) | null)?.()
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
;(this.onopen as (() => void) | null)?.()
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
;(this.onclose as (() => void) | null)?.()
|
||||
}
|
||||
}
|
||||
|
||||
describe('wsManager reconnect buffering', () => {
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
FakeWebSocket.instances = []
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
wsManager.disconnectAll()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wsManager.disconnectAll()
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('replays queued messages after an unexpected reconnect', async () => {
|
||||
wsManager.connect('session-reconnect')
|
||||
|
||||
const firstSocket = FakeWebSocket.instances[0]
|
||||
expect(firstSocket?.url).toContain('/ws/session-reconnect')
|
||||
|
||||
firstSocket!.open()
|
||||
wsManager.send('session-reconnect', { type: 'user_message', content: 'first' })
|
||||
expect(firstSocket!.sent).toEqual([
|
||||
JSON.stringify({ type: 'user_message', content: 'first' }),
|
||||
])
|
||||
|
||||
firstSocket!.fail()
|
||||
wsManager.send('session-reconnect', { type: 'user_message', content: 'queued while offline' })
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
const secondSocket = FakeWebSocket.instances[1]
|
||||
expect(secondSocket).toBeDefined()
|
||||
secondSocket!.open()
|
||||
|
||||
expect(secondSocket!.sent).toEqual([
|
||||
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { isTauriRuntime } from '../../lib/desktopRuntime'
|
||||
import { useUpdateStore } from '../../stores/updateStore'
|
||||
import { formatBytes } from '../../lib/formatBytes'
|
||||
|
||||
export function UpdateChecker() {
|
||||
const t = useTranslation()
|
||||
const status = useUpdateStore((s) => s.status)
|
||||
const availableVersion = useUpdateStore((s) => s.availableVersion)
|
||||
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
|
||||
const progressPercent = useUpdateStore((s) => s.progressPercent)
|
||||
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
|
||||
const totalBytes = useUpdateStore((s) => s.totalBytes)
|
||||
const error = useUpdateStore((s) => s.error)
|
||||
const shouldPrompt = useUpdateStore((s) => s.shouldPrompt)
|
||||
const initialize = useUpdateStore((s) => s.initialize)
|
||||
const installUpdate = useUpdateStore((s) => s.installUpdate)
|
||||
const dismissPrompt = useUpdateStore((s) => s.dismissPrompt)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [initialize])
|
||||
|
||||
if (!isTauriRuntime()) return null
|
||||
|
||||
const showPopup =
|
||||
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
|
||||
|
||||
if (!showPopup) return null
|
||||
|
||||
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
|
||||
const downloadedText = formatBytes(downloadedBytes)
|
||||
const statusText =
|
||||
status === 'restarting'
|
||||
? t('update.restarting')
|
||||
: status === 'downloading'
|
||||
? hasKnownProgress
|
||||
? t('update.downloading')
|
||||
: t('update.progressBytes', { downloaded: downloadedText })
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-[200] max-w-sm">
|
||||
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-4">
|
||||
<p className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('update.available', { version: availableVersion })}
|
||||
</p>
|
||||
|
||||
{releaseNotes && (
|
||||
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)]/70 px-3 py-2">
|
||||
<MarkdownRenderer
|
||||
content={releaseNotes}
|
||||
className="text-xs leading-5 text-[var(--color-text-secondary)] [&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:mb-1.5 [&_h2]:text-xs [&_h2]:font-semibold [&_p]:my-1.5 [&_p]:text-xs [&_p]:leading-5 [&_ul]:my-1.5 [&_ol]:my-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'downloading' || status === 'restarting') && (
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
|
||||
{hasKnownProgress || status === 'restarting' ? (
|
||||
<div
|
||||
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
{statusText && (
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{statusText}
|
||||
{status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-[var(--color-error)]">
|
||||
{t('update.failed', { error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'available' && (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => void installUpdate()}
|
||||
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t('update.now')}
|
||||
</button>
|
||||
<button
|
||||
onClick={dismissPrompt}
|
||||
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
>
|
||||
{t('update.later')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type Props = {
|
||||
selected: number[]
|
||||
onChange: (days: number[]) => void
|
||||
}
|
||||
|
||||
// Display order: Mon(1) → Sun(0), matching Chinese convention
|
||||
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
const DAY_KEYS = [
|
||||
'newTask.daySun',
|
||||
'newTask.dayMon',
|
||||
'newTask.dayTue',
|
||||
'newTask.dayWed',
|
||||
'newTask.dayThu',
|
||||
'newTask.dayFri',
|
||||
'newTask.daySat',
|
||||
] as const
|
||||
|
||||
export function DayOfWeekPicker({ selected, onChange }: Props) {
|
||||
const t = useTranslation()
|
||||
|
||||
const toggle = (day: number) => {
|
||||
if (selected.includes(day)) {
|
||||
// Don't allow deselecting the last day
|
||||
if (selected.length <= 1) return
|
||||
onChange(selected.filter((d) => d !== day))
|
||||
} else {
|
||||
onChange([...selected, day])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-1.5">
|
||||
{DAY_ORDER.map((day) => {
|
||||
const isActive = selected.includes(day)
|
||||
return (
|
||||
<button
|
||||
key={day}
|
||||
type="button"
|
||||
onClick={() => toggle(day)}
|
||||
className={`
|
||||
w-8 h-8 rounded-full text-xs font-medium transition-colors
|
||||
${isActive
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] border border-[var(--color-border-focus)]'
|
||||
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{t(DAY_KEYS[day]!)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import { useCLITaskStore } from '../stores/cliTaskStore'
|
||||
import { useTeamStore } from '../stores/teamStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { MessageList } from '../components/chat/MessageList'
|
||||
import { ChatInput } from '../components/chat/ChatInput'
|
||||
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
|
||||
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
|
||||
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
|
||||
|
||||
const TASK_POLL_INTERVAL_MS = 1000
|
||||
|
||||
export function ActiveSession() {
|
||||
const activeTabId = useTabStore((s) => s.activeTabId)
|
||||
const sessions = useSessionStore((s) => s.sessions)
|
||||
const connectToSession = useChatStore((s) => s.connectToSession)
|
||||
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
|
||||
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
|
||||
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
|
||||
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
|
||||
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
|
||||
const chatState = sessionState?.chatState ?? 'idle'
|
||||
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
|
||||
|
||||
const session = sessions.find((s) => s.id === activeTabId)
|
||||
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
|
||||
const activeTeam = useTeamStore((s) => s.activeTeam)
|
||||
const isMemberSession = !!memberInfo
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTabId && !isMemberSession) {
|
||||
connectToSession(activeTabId)
|
||||
}
|
||||
}, [activeTabId, isMemberSession, connectToSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || isMemberSession) return
|
||||
|
||||
const shouldPollTasks =
|
||||
chatState !== 'idle' ||
|
||||
(trackedTaskSessionId === activeTabId && hasIncompleteTasks)
|
||||
|
||||
if (!shouldPollTasks) return
|
||||
|
||||
void fetchSessionTasks(activeTabId)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void fetchSessionTasks(activeTabId)
|
||||
}, TASK_POLL_INTERVAL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [
|
||||
activeTabId,
|
||||
isMemberSession,
|
||||
chatState,
|
||||
trackedTaskSessionId,
|
||||
hasIncompleteTasks,
|
||||
fetchSessionTasks,
|
||||
])
|
||||
|
||||
const t = useTranslation()
|
||||
const messages = sessionState?.messages ?? []
|
||||
const streamingText = sessionState?.streamingText ?? ''
|
||||
const isEmpty = messages.length === 0 && !streamingText
|
||||
|
||||
const isActive = chatState !== 'idle'
|
||||
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
|
||||
|
||||
const lastUpdated = useMemo(() => {
|
||||
if (!session?.modifiedAt) return ''
|
||||
const diff = Date.now() - new Date(session.modifiedAt).getTime()
|
||||
if (diff < 60000) return t('session.timeJustNow')
|
||||
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
|
||||
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
|
||||
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
|
||||
}, [session?.modifiedAt, t])
|
||||
|
||||
if (!activeTabId) return null
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
|
||||
{isMemberSession && (
|
||||
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{memberInfo?.status === 'running' && (
|
||||
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
|
||||
)}
|
||||
{memberInfo?.status === 'completed' && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{memberInfo?.role}
|
||||
</span>
|
||||
{activeTeam && (
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
@ {activeTeam.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{t('teams.memberSessionHint')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeTeam?.leadSessionId) {
|
||||
useTabStore.getState().openTab(
|
||||
activeTeam.leadSessionId,
|
||||
t('teams.leader'),
|
||||
'session',
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={!activeTeam?.leadSessionId}
|
||||
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
|
||||
{t('teams.backToLeader')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
|
||||
<div className="flex max-w-md flex-col items-center text-center">
|
||||
{isMemberSession ? (
|
||||
<>
|
||||
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
|
||||
<p className="text-[var(--color-text-secondary)]">
|
||||
{memberInfo?.status === 'running'
|
||||
? `${memberInfo.role} ${t('teams.working')}`
|
||||
: t('teams.noMessages')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
|
||||
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
|
||||
{t('empty.title')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
{t('empty.subtitle')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isMemberSession && (
|
||||
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
|
||||
{session?.title || t('session.untitled')}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
|
||||
{isActive && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
|
||||
{t('session.active')}
|
||||
</span>
|
||||
)}
|
||||
{totalTokens > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{totalTokens.toLocaleString()} t</span>
|
||||
</>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
|
||||
</>
|
||||
)}
|
||||
{session?.messageCount !== undefined && session.messageCount > 0 && (
|
||||
<>
|
||||
<span className="text-[var(--color-outline)]">·</span>
|
||||
<span>{t('session.messages', { count: session.messageCount })}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{session?.workDirExists === false && (
|
||||
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span className="truncate">
|
||||
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageList />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isMemberSession && <SessionTaskBar />}
|
||||
|
||||
<TeamStatusBar />
|
||||
|
||||
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
|
||||
|
||||
{!isMemberSession && activeTabId ? (
|
||||
<ComputerUsePermissionModal
|
||||
sessionId={activeTabId}
|
||||
request={pendingComputerUsePermission?.request ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { useTranslation } from '../i18n'
|
||||
import { mockScheduledTasks, mockStatusBar } from '../mocks/data'
|
||||
|
||||
export function ScheduledTasksList() {
|
||||
const t = useTranslation()
|
||||
const { stats, tasks } = mockScheduledTasks
|
||||
const task0 = tasks[0]!
|
||||
const task1 = tasks[1]!
|
||||
const task2 = tasks[2]!
|
||||
|
||||
return (
|
||||
<div className="bg-[#FAF9F5] text-[#1B1C1A] flex min-h-screen overflow-hidden font-[Inter,sans-serif]">
|
||||
{/* SideNavBar */}
|
||||
<aside className="fixed left-0 top-0 h-full w-[280px] bg-[#F4F4F0] flex flex-col p-4 gap-2 z-40">
|
||||
<div className="mb-6 px-2 flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-[#AD5F45] flex items-center justify-center">
|
||||
<span
|
||||
className="material-symbols-outlined text-white"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
folder_managed
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-[Manrope,sans-serif] text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">{t('sidebar.allProjects')}</h2>
|
||||
<p className="text-xs text-[#87736D] font-medium">{t('scheduledPage.activeSession')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
{t('sidebar.newSession')}
|
||||
</button>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full font-medium text-sm duration-200 ease-in-out">
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
calendar_today
|
||||
</span>
|
||||
{t('sidebar.scheduled')}
|
||||
</button>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
{t('sidebar.timeGroup.today')}
|
||||
</button>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">event_note</span>
|
||||
{t('sidebar.timeGroup.last7days')}
|
||||
</button>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">archive</span>
|
||||
{t('sidebar.timeGroup.older')}
|
||||
</button>
|
||||
|
||||
<div className="mt-auto pt-4 flex flex-col gap-2">
|
||||
<div className="px-2 py-4">
|
||||
<button className="w-full bg-[#E9E8E4] text-[#1B1C1A] font-[Manrope,sans-serif] text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-[#E3E2DF] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1rem]">search</span>
|
||||
{t('sidebar.searchPlaceholder')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="h-[1px] bg-[#DAC1BA]/20 mx-2 mb-2"></div>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">computer</span>
|
||||
{t('scheduledPage.localMode')}
|
||||
</button>
|
||||
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
|
||||
<span className="material-symbols-outlined">cloud</span>
|
||||
{t('scheduledPage.remoteMode')}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 flex flex-col ml-[280px] min-w-0 h-screen">
|
||||
{/* TopAppBar */}
|
||||
<header className="bg-[#FAF9F5] h-12 w-full flex justify-between items-center px-6 z-30">
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] uppercase tracking-tighter text-sm">Claude Code Companion</div>
|
||||
<nav className="flex items-center gap-6 font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
|
||||
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.code')}</a>
|
||||
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.terminal')}</a>
|
||||
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1" href="#">{t('titlebar.history')}</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
|
||||
<span className="material-symbols-outlined text-[1rem]">arrow_back_ios</span>
|
||||
</button>
|
||||
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
|
||||
<span className="material-symbols-outlined text-[1rem]">arrow_forward_ios</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className="font-[Manrope,sans-serif] font-semibold tracking-wide text-sm text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">settings</span>
|
||||
{t('sidebar.settings')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Separation Line */}
|
||||
<div className="bg-[#F4F4F0] h-[1px] w-full"></div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<section className="flex-1 overflow-y-auto p-12 bg-[#FAF9F5]">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Page Header */}
|
||||
<div className="flex justify-between items-end mb-12">
|
||||
<div className="space-y-1">
|
||||
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[#1B1C1A]">{t('scheduledPage.title')}</h1>
|
||||
<p className="text-[#87736D] text-sm">{t('scheduledPage.subtitle')}</p>
|
||||
</div>
|
||||
<button className="bg-[#8F482F] hover:bg-[#AD5F45] text-white px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">add_task</span>
|
||||
{t('tasks.createNew')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bento-style Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
{/* Total Tasks */}
|
||||
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('tasks.totalTasks')}</span>
|
||||
<span className="material-symbols-outlined text-[#8F482F]">analytics</span>
|
||||
</div>
|
||||
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.totalTasks}</div>
|
||||
<div className="mt-2 flex items-center gap-1 text-[10px] text-[#4F6237] font-bold bg-[#677B4E]/20 px-2 py-0.5 rounded-full w-fit">
|
||||
<span className="material-symbols-outlined text-[10px]">trending_up</span>
|
||||
{t('scheduledPage.thisMonth', { count: '+2' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Run */}
|
||||
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.nextRun')}</span>
|
||||
<span className="material-symbols-outlined text-[#2D628F]">schedule</span>
|
||||
</div>
|
||||
<div className="text-xl font-[Manrope,sans-serif] font-bold text-[#1B1C1A]">{stats.nextRun.name}</div>
|
||||
<p className="text-sm font-[JetBrains_Mono,monospace] text-[#2D628F] mt-1">{stats.nextRun.time}</p>
|
||||
</div>
|
||||
|
||||
{/* System Health */}
|
||||
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.systemHealth')}</span>
|
||||
<span className="material-symbols-outlined text-[#4F6237]">check_circle</span>
|
||||
</div>
|
||||
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.systemHealth}%</div>
|
||||
<p className="text-xs text-[#87736D] mt-2 font-medium">{stats.healthPeriod}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operational Tasks Table */}
|
||||
<div className="bg-white rounded-xl overflow-hidden border border-[#DAC1BA]/20 shadow-[0_4px_20px_rgba(27,28,26,0.04)]">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-[#F4F4F0]/50">
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colTaskName')}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colFrequency')}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colLastResult')}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colNextExecution')}</th>
|
||||
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10 text-right">{t('scheduledPage.colActions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#DAC1BA]/5">
|
||||
{/* Task Row 1 - Nightly linting */}
|
||||
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#FFDBD0] text-[#8F482F] rounded-lg">
|
||||
<span className="material-symbols-outlined text-[1.2rem]">code_blocks</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task0.name}</div>
|
||||
<div className="text-xs text-[#87736D] font-medium">Root: /projects/companion/src</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task0.frequency}</span>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
|
||||
<span
|
||||
className="material-symbols-outlined text-[1rem]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
{task0.lastResult}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task0.nextExecution}</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Task Row 2 - Clean up temp files */}
|
||||
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#CFE5FF] text-[#094A76] rounded-lg">
|
||||
<span className="material-symbols-outlined text-[1.2rem]">cleaning_services</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task1.name}</div>
|
||||
<div className="text-xs text-[#87736D] font-medium">{task1.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task1.frequency}</span>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
|
||||
<span
|
||||
className="material-symbols-outlined text-[1rem]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
check_circle
|
||||
</span>
|
||||
{task1.lastResult}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task1.nextExecution}</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Task Row 3 - Database Vacuum */}
|
||||
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-[#D4EAB4] text-[#3B4C24] rounded-lg">
|
||||
<span className="material-symbols-outlined text-[1.2rem]">database</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task2.name}</div>
|
||||
<div className="text-xs text-[#87736D] font-medium">{task2.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">Monthly</span>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center gap-1.5 text-[#BA1A1A] text-xs font-bold">
|
||||
<span
|
||||
className="material-symbols-outlined text-[1rem]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
error
|
||||
</span>
|
||||
{task2.lastResult}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task2.nextExecution}</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
|
||||
</button>
|
||||
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
|
||||
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* End of list placeholder */}
|
||||
<div className="p-12 text-center border-t border-[#DAC1BA]/10">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-[#F4F4F0] mb-4">
|
||||
<span className="material-symbols-outlined text-[#87736D]">history_toggle_off</span>
|
||||
</div>
|
||||
<h3 className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-base">{t('scheduledPage.endOfList')}</h3>
|
||||
<p className="text-sm text-[#87736D] max-w-xs mx-auto mt-1">{t('scheduledPage.pausedTasks')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Logs / Details Panel */}
|
||||
<div className="mt-12 flex flex-col md:flex-row gap-8 items-start">
|
||||
{/* Recent Output Logs */}
|
||||
<div className="flex-1 space-y-6">
|
||||
<h2 className="font-[Manrope,sans-serif] text-lg font-bold text-[#1B1C1A]">{t('scheduledPage.recentLogs')}</h2>
|
||||
<div className="bg-[#DBDAD6] rounded-xl p-6 font-[JetBrains_Mono,monospace] text-[13px] leading-relaxed text-[#54433E] overflow-x-auto shadow-inner">
|
||||
<div className="flex gap-4 opacity-50 mb-1">
|
||||
<span className="w-32 shrink-0">2023-11-10 23:01</span>
|
||||
<span className="text-[#4F6237]">[INFO]</span>
|
||||
<span>Nightly linting started for repository: companion-main</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mb-1">
|
||||
<span className="w-32 shrink-0">2023-11-10 23:04</span>
|
||||
<span className="text-[#4F6237]">[INFO]</span>
|
||||
<span>Processed 1,422 files. No critical issues found.</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mb-1">
|
||||
<span className="w-32 shrink-0">2023-11-10 23:04</span>
|
||||
<span className="text-[#094A76]">[WARN]</span>
|
||||
<span className="italic">Found 12 deprecated calls in /legacy/utils.js</span>
|
||||
</div>
|
||||
<div className="flex gap-4 mb-1">
|
||||
<span className="w-32 shrink-0">2023-11-10 23:05</span>
|
||||
<span className="text-[#4F6237]">[INFO]</span>
|
||||
<span>Task completed successfully in 242.4s.</span>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-[#DAC1BA]/20 flex items-center justify-between">
|
||||
<span className="text-[11px] uppercase tracking-tighter opacity-50">Log stream: active</span>
|
||||
<button className="text-[#8F482F] font-bold text-xs hover:underline">{t('scheduledPage.viewArtifacts')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Allocation Panel */}
|
||||
<div className="w-full md:w-80 shrink-0">
|
||||
<div className="bg-[#AD5F45]/10 p-6 rounded-xl border border-[#8F482F]/10">
|
||||
<h3 className="font-[Manrope,sans-serif] font-bold text-[#8F482F] text-sm mb-3">{t('scheduledPage.resourceAllocation')}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
|
||||
<span>{t('scheduledPage.cpuCapacity')}</span>
|
||||
<span>42%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#8F482F]" style={{ width: '42%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
|
||||
<span>{t('scheduledPage.memoryLoad')}</span>
|
||||
<span>68%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-[#2D628F]" style={{ width: '68%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div className="w-full h-24 rounded-lg bg-gradient-to-br from-[#FFDBD0] via-[#FFB59D]/40 to-[#DAC1BA]/20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-[#FAF9F5] border-t border-[#87736D]/20 fixed bottom-0 left-0 w-full h-8 flex items-center justify-between px-4 z-50">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D]">{mockStatusBar.user} • {mockStatusBar.username} • {mockStatusBar.plan}</span>
|
||||
<div className="h-3 w-[1px] bg-[#87736D]/30"></div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-[10px] text-[#4F6237]"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
fiber_manual_record
|
||||
</span>
|
||||
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#1B1C1A]">{t('scheduledPage.connectedLocal')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">account_tree</span>
|
||||
{mockStatusBar.branch}
|
||||
</button>
|
||||
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">layers</span>
|
||||
{mockStatusBar.worktreeToggle}
|
||||
</button>
|
||||
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#8F482F] font-bold hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">toggle_on</span>
|
||||
{mockStatusBar.localSwitch}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from 'zustand'
|
||||
import { agentsApi, type AgentDefinition } from '../api/agents'
|
||||
|
||||
export type AgentDetailReturnTab = 'agents' | 'plugins'
|
||||
|
||||
type AgentStore = {
|
||||
activeAgents: AgentDefinition[]
|
||||
allAgents: AgentDefinition[]
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
selectedAgent: AgentDefinition | null
|
||||
selectedAgentReturnTab: AgentDetailReturnTab
|
||||
|
||||
fetchAgents: (cwd?: string) => Promise<void>
|
||||
selectAgent: (
|
||||
agent: AgentDefinition | null,
|
||||
returnTab?: AgentDetailReturnTab,
|
||||
) => void
|
||||
}
|
||||
|
||||
export const useAgentStore = create<AgentStore>((set) => ({
|
||||
activeAgents: [],
|
||||
allAgents: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
selectedAgent: null,
|
||||
selectedAgentReturnTab: 'agents',
|
||||
|
||||
fetchAgents: async (cwd) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { activeAgents, allAgents } = await agentsApi.list(cwd)
|
||||
set({ activeAgents, allAgents, isLoading: false })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load agents'
|
||||
set({ isLoading: false, error: message })
|
||||
}
|
||||
},
|
||||
|
||||
selectAgent: (agent, returnTab = 'agents') =>
|
||||
set({
|
||||
selectedAgent: agent,
|
||||
selectedAgentReturnTab: agent ? returnTab : 'agents',
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,56 @@
|
||||
// Source: src/server/services/cronService.ts
|
||||
|
||||
export type TaskNotificationConfig = {
|
||||
enabled: boolean
|
||||
channels: ('telegram' | 'feishu')[]
|
||||
}
|
||||
|
||||
export type CronTask = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
cron: string
|
||||
prompt: string
|
||||
enabled: boolean
|
||||
recurring?: boolean
|
||||
permanent?: boolean
|
||||
createdAt: number
|
||||
lastRunAt?: number
|
||||
lastFiredAt?: string
|
||||
nextRunAt?: number
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
}
|
||||
|
||||
export type CreateTaskInput = {
|
||||
name: string
|
||||
description?: string
|
||||
cron: string
|
||||
prompt: string
|
||||
enabled?: boolean
|
||||
recurring?: boolean
|
||||
permanent?: boolean
|
||||
permissionMode?: string
|
||||
model?: string
|
||||
folderPath?: string
|
||||
useWorktree?: boolean
|
||||
notification?: TaskNotificationConfig
|
||||
}
|
||||
|
||||
export type TaskRun = {
|
||||
id: string
|
||||
taskId: string
|
||||
taskName: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
status: 'running' | 'completed' | 'failed' | 'timeout'
|
||||
prompt: string
|
||||
output?: string
|
||||
error?: string
|
||||
exitCode?: number
|
||||
durationMs?: number
|
||||
sessionId?: string
|
||||
}
|
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 547 KiB |
@@ -0,0 +1,34 @@
|
||||
# FAQ
|
||||
|
||||
|
||||
## Q: `undefined is not an object (evaluating 'usage.input_tokens')`
|
||||
|
||||
**Cause**: `ANTHROPIC_BASE_URL` is misconfigured. The API endpoint is returning HTML or another non-JSON format instead of a valid Anthropic protocol response.
|
||||
|
||||
This project uses the **Anthropic Messages API protocol**. `ANTHROPIC_BASE_URL` must point to an endpoint compatible with Anthropic's `/v1/messages` interface. The Anthropic SDK automatically appends `/v1/messages` to the base URL, so:
|
||||
|
||||
- MiniMax: `ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic` ✅
|
||||
- OpenRouter: `ANTHROPIC_BASE_URL=https://openrouter.ai/api` ✅
|
||||
- OpenRouter (wrong): `ANTHROPIC_BASE_URL=https://openrouter.ai/anthropic` ❌ (returns HTML)
|
||||
|
||||
If your model provider only supports the OpenAI protocol, you need a proxy like LiteLLM for protocol translation. See the [Third-Party Models Guide](./third-party-models.md).
|
||||
|
||||
## Q: `Cannot find package 'bundle'`
|
||||
|
||||
```
|
||||
error: Cannot find package 'bundle' from '.../claude-code-haha/src/entrypoints/cli.tsx'
|
||||
```
|
||||
|
||||
**Cause**: Your Bun version is too old and doesn't support the required `bun:bundle` built-in module.
|
||||
|
||||
**Fix**: Upgrade Bun to the latest version:
|
||||
|
||||
```bash
|
||||
bun upgrade
|
||||
```
|
||||
|
||||
## Q: How to use OpenAI / DeepSeek / Ollama or other non-Anthropic models?
|
||||
|
||||
This project only supports the Anthropic protocol. If your model provider doesn't natively support the Anthropic protocol, you need a proxy like [LiteLLM](https://github.com/BerriAI/litellm) for protocol translation (OpenAI → Anthropic).
|
||||
|
||||
See the [Third-Party Models Guide](./third-party-models.md) for detailed setup instructions.
|
||||
|
After Width: | Height: | Size: 430 KiB |
@@ -0,0 +1,64 @@
|
||||
# 快速开始
|
||||
|
||||
## 1. 安装 Bun
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
|
||||
# macOS (Homebrew)
|
||||
brew install bun
|
||||
|
||||
# Windows (PowerShell)
|
||||
powershell -c "irm bun.sh/install.ps1 | iex"
|
||||
```
|
||||
|
||||
> 精简版 Linux 如提示 `unzip is required`,先运行 `apt update && apt install -y unzip`
|
||||
|
||||
## 2. 安装依赖并配置
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.example .env
|
||||
# 编辑 .env 填入你的 API Key,详见「环境变量」文档
|
||||
```
|
||||
|
||||
环境变量的完整说明请参考 [环境变量配置](./env-vars.md)。
|
||||
|
||||
## 3. 启动
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
./bin/claude-haha # 交互 TUI 模式
|
||||
./bin/claude-haha -p "your prompt here" # 无头模式
|
||||
./bin/claude-haha --help # 查看所有选项
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
> **前置要求**:必须安装 [Git for Windows](https://git-scm.com/download/win)
|
||||
|
||||
```powershell
|
||||
# PowerShell / cmd 直接调用 Bun
|
||||
bun --env-file=.env ./src/entrypoints/cli.tsx
|
||||
|
||||
# 或在 Git Bash 中运行
|
||||
./bin/claude-haha
|
||||
```
|
||||
|
||||
## 4. 全局使用(可选)
|
||||
|
||||
将 `bin/` 加入 PATH 后可在任意目录启动,详见 [全局使用指南](./global-usage.md):
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
|
||||
```
|
||||
|
||||
## 5. 降级模式
|
||||
|
||||
如果 Ink TUI 出现问题,可以使用降级 Recovery CLI 模式:
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha
|
||||
```
|
||||
|
After Width: | Height: | Size: 463 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 710 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,316 @@
|
||||
# Claude Code 记忆系统 — AutoDream 记忆整合
|
||||
|
||||
> Claude 会"做梦"——在后台静默回顾近期会话,整合、更新、修剪记忆,就像人类睡眠中整理白天的记忆一样。
|
||||
|
||||
<p align="center">
|
||||
<a href="#一什么是-autodream">AutoDream</a> · <a href="#二触发条件">触发条件</a> · <a href="#三四阶段整合流程">整合流程</a> · <a href="#四安全限制">安全限制</a> · <a href="#五ui-展示">UI 展示</a> · <a href="#六配置与开关">配置开关</a> · <a href="#七与-extractmemories-的关系">对比</a> · <a href="#八源码导航">源码导航</a>
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 一、什么是 AutoDream?
|
||||
|
||||
AutoDream 是 Claude Code 的 **后台记忆整合机制**,内部代号 **"Dream: Memory Consolidation"**。
|
||||
|
||||
核心隐喻:
|
||||
|
||||
| 人类 | Claude Code |
|
||||
|------|-------------|
|
||||
| 白天随手记笔记 | `extractMemories` — 每次对话后提取新记忆 |
|
||||
| 晚上睡觉时整理笔记本 | `autoDream` — 定期回顾多个会话,整合全部记忆 |
|
||||
|
||||
当你不活跃时(默认间隔 24 小时、积累 5 个会话后),Claude 会在后台静默启动一个 **"做梦"子智能体**(forked subagent),回顾所有近期会话记录,将零散的记忆整合为结构化的、去重的、去过时的持久化知识。
|
||||
|
||||
**关键源码**:`src/services/autoDream/autoDream.ts`
|
||||
|
||||
```typescript
|
||||
// Background memory consolidation. Fires the /dream prompt as a forked
|
||||
// subagent when time-gate passes AND enough sessions have accumulated.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、触发条件
|
||||
|
||||

|
||||
|
||||
AutoDream 采用 **五重门控** 机制,按开销从低到高逐级检查:
|
||||
|
||||
### 门控链
|
||||
|
||||
| 序号 | 门控 | 说明 | 开销 |
|
||||
|------|------|------|------|
|
||||
| 1 | **功能开关** | `isAutoDreamEnabled()` + 非 KAIROS + 非远程模式 + autoMemory 已启用 | 内存读取 |
|
||||
| 2 | **时间门控** | 距上次整合 >= `minHours`(默认 24h) | 1 次 stat |
|
||||
| 3 | **扫描节流** | 上次扫描后至少等 10 分钟再重新扫描 | 时间戳比较 |
|
||||
| 4 | **会话门控** | 自上次整合后至少 `minSessions` 个新会话(默认 5 个,排除当前) | 目录扫描 |
|
||||
| 5 | **锁门控** | 没有其他进程正在做梦(PID 锁文件) | stat + read |
|
||||
|
||||
**关键源码** `src/services/autoDream/autoDream.ts:63-66`:
|
||||
|
||||
```typescript
|
||||
const DEFAULTS: AutoDreamConfig = {
|
||||
minHours: 24, // 距离上次整合至少 24 小时
|
||||
minSessions: 5, // 期间至少积累了 5 个会话
|
||||
}
|
||||
```
|
||||
|
||||
### 扫描节流
|
||||
|
||||
当时间门控通过但会话门控未通过时,锁文件的 mtime 不会更新,导致时间门控在后续每个 turn 都会通过。为避免频繁的目录扫描,设有 **10 分钟的扫描节流**:
|
||||
|
||||
```typescript
|
||||
const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
|
||||
```
|
||||
|
||||
### 执行入口
|
||||
|
||||
AutoDream 在每次 AI 回复完成后的 stop hook 阶段被触发(fire-and-forget,不阻塞主线程):
|
||||
|
||||
**关键源码** `src/query/stopHooks.ts:154-156`:
|
||||
|
||||
```typescript
|
||||
if (!toolUseContext.agentId) {
|
||||
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
|
||||
}
|
||||
```
|
||||
|
||||
### 阻止条件
|
||||
|
||||
以下情况不会触发 AutoDream:
|
||||
|
||||
- **KAIROS 模式**:使用独立的 disk-skill dream
|
||||
- **远程模式**:`getIsRemoteMode() === true`
|
||||
- **autoMemory 未启用**
|
||||
- **`--bare` / SIMPLE 模式**
|
||||
- **子代理内**:只有主代理才触发
|
||||
|
||||
---
|
||||
|
||||
## 三、四阶段整合流程
|
||||
|
||||

|
||||
|
||||
一旦所有门控通过,AutoDream 启动一个 **分叉子智能体**,按照 `consolidationPrompt.ts` 定义的 4 阶段提示词工作:
|
||||
|
||||
### Phase 1 — Orient(定向)
|
||||
|
||||
```
|
||||
- ls 记忆目录,查看已有文件
|
||||
- 读取 MEMORY.md 索引,理解当前知识结构
|
||||
- 浏览现有主题文件,避免创建重复
|
||||
- 如存在 logs/ 或 sessions/ 子目录,检查最近条目
|
||||
```
|
||||
|
||||
### Phase 2 — Gather recent signal(收集近期信号)
|
||||
|
||||
按优先级从高到低搜集信息:
|
||||
|
||||
1. **Daily logs** — `logs/YYYY/MM/YYYY-MM-DD.md`(追加流日志)
|
||||
2. **漂移的记忆** — 与代码库现状矛盾的旧事实
|
||||
3. **会话记录搜索** — 用 `grep` 在 JSONL 转录文件中窄范围检索
|
||||
|
||||
```
|
||||
不要穷尽读取转录文件。只查找你已经怀疑重要的内容。
|
||||
```
|
||||
|
||||
### Phase 3 — Consolidate(整合)
|
||||
|
||||
- **合并**新信号到已有主题文件(而非创建新的近似文件)
|
||||
- **转换**相对日期为绝对日期("昨天" → "2026-04-03")
|
||||
- **删除**被推翻的旧事实
|
||||
|
||||
### Phase 4 — Prune and index(修剪与索引)
|
||||
|
||||
- 更新 `MEMORY.md`,保持 ≤ 规定行数且 ≤ 25KB
|
||||
- 删除指向过时记忆的指针
|
||||
- 压缩冗长条目(>200 字符的索引行,将内容移入主题文件)
|
||||
- 新增指向重要记忆的指针
|
||||
- 解决矛盾(两个文件意见不一致时,修复错误的那个)
|
||||
|
||||
**关键源码** `src/services/autoDream/consolidationPrompt.ts:10-64`
|
||||
|
||||
---
|
||||
|
||||
## 四、安全限制
|
||||
|
||||
AutoDream 子智能体受到严格的工具权限限制:
|
||||
|
||||
### Bash 仅限只读
|
||||
|
||||
```
|
||||
允许:ls, find, grep, cat, stat, wc, head, tail
|
||||
拒绝:所有写入、重定向、修改状态的命令
|
||||
```
|
||||
|
||||
### 文件操作仅限记忆目录
|
||||
|
||||
`createAutoMemCanUseTool()` 是 `extractMemories` 和 `autoDream` 共享的权限函数:
|
||||
|
||||
```
|
||||
允许 Read / Grep / Glob — 无限制
|
||||
允许 Edit / Write — 仅 auto-memory 目录内
|
||||
拒绝 MCP / Agent / 非只读 Bash / 其他写操作
|
||||
```
|
||||
|
||||
**关键源码** `src/services/extractMemories/extractMemories.ts:167-171`
|
||||
|
||||
### 锁文件机制
|
||||
|
||||
使用 `.consolidate-lock` 文件实现进程级互斥:
|
||||
|
||||
| 机制 | 说明 |
|
||||
|------|------|
|
||||
| **锁内容** | 持有者 PID |
|
||||
| **时间戳** | 锁文件 mtime = 上次整合时间 |
|
||||
| **过期** | 持有超过 1 小时视为过期(防 PID 复用) |
|
||||
| **竞争** | 两个进程同时写入 → 最后写入者获胜,失败者在 re-read 时退出 |
|
||||
| **回滚** | 失败时回退 mtime 到获取前的值,让下次尝试不受影响 |
|
||||
| **崩溃恢复** | mtime 卡住 + 死 PID → 下个进程回收锁 |
|
||||
|
||||
**关键源码** `src/services/autoDream/consolidationLock.ts`
|
||||
|
||||
---
|
||||
|
||||
## 五、UI 展示
|
||||
|
||||
### 底部状态栏
|
||||
|
||||
当 AutoDream 运行时,底部状态栏会显示 **"dreaming"** 标签:
|
||||
|
||||
```typescript
|
||||
// src/tasks/pillLabel.ts:61-62
|
||||
case 'dream':
|
||||
return 'dreaming'
|
||||
```
|
||||
|
||||
### 任务详情对话框
|
||||
|
||||
用户按 `Shift+Down` 可打开后台任务对话框,查看梦境的实时进度:
|
||||
|
||||
- **DreamDetailDialog** 组件展示:
|
||||
- 正在回顾的会话数量
|
||||
- 当前阶段:`starting`(正在分析)→ `updating`(正在修改记忆文件)
|
||||
- 最近的助手文本响应和工具调用次数
|
||||
- 已触碰的文件路径列表
|
||||
|
||||
- **用户可按 `x` 键终止**正在进行的梦境(触发 abort + 锁回滚)
|
||||
|
||||
### 完成通知
|
||||
|
||||
梦境完成后,如果有文件被修改,会在主会话中显示内联通知:
|
||||
|
||||
```typescript
|
||||
appendSystemMessage({
|
||||
...createMemorySavedMessage(dreamState.filesTouched),
|
||||
verb: 'Improved',
|
||||
})
|
||||
```
|
||||
|
||||
**关键源码**:
|
||||
- `src/tasks/DreamTask/DreamTask.ts` — 任务状态管理
|
||||
- `src/components/tasks/DreamDetailDialog.tsx` — UI 组件
|
||||
|
||||
---
|
||||
|
||||
## 六、配置与开关
|
||||
|
||||
### settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"autoDreamEnabled": true
|
||||
}
|
||||
```
|
||||
|
||||
- **显式设置时**:直接使用用户的值
|
||||
- **未设置时**:由远程 GrowthBook feature flag `tengu_onyx_plover` 控制
|
||||
|
||||
**关键源码** `src/services/autoDream/config.ts:13-21`:
|
||||
|
||||
```typescript
|
||||
export function isAutoDreamEnabled(): boolean {
|
||||
const setting = getInitialSettings().autoDreamEnabled
|
||||
if (setting !== undefined) return setting
|
||||
const gb = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: unknown } | null>(
|
||||
'tengu_onyx_plover', null,
|
||||
)
|
||||
return gb?.enabled === true
|
||||
}
|
||||
```
|
||||
|
||||
### 远程配置参数
|
||||
|
||||
`tengu_onyx_plover` feature flag 可配置:
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `enabled` | boolean | — | 功能总开关 |
|
||||
| `minHours` | number | 24 | 最小间隔(小时) |
|
||||
| `minSessions` | number | 5 | 最小会话数 |
|
||||
|
||||
### 手动触发 `/dream`
|
||||
|
||||
除了自动触发,用户可以通过 `/dream` 命令手动触发记忆整合。手动触发会调用 `recordConsolidation()` 更新锁文件时间戳。
|
||||
|
||||
---
|
||||
|
||||
## 七、与 extractMemories 的关系
|
||||
|
||||
| 维度 | extractMemories | autoDream |
|
||||
|------|----------------|-----------|
|
||||
| **触发频率** | 每次对话回合结束 | 每 24h + 5 个会话 |
|
||||
| **触发位置** | `stopHooks.ts` L149 | `stopHooks.ts` L155 |
|
||||
| **处理范围** | 当前对话最近的消息 | 多个会话的历史记录 |
|
||||
| **目标** | 提取新记忆 | 整合/去重/修剪已有记忆 |
|
||||
| **人类类比** | 白天随手记笔记 | 睡觉时整理笔记本 |
|
||||
| **共享组件** | `createAutoMemCanUseTool` | `createAutoMemCanUseTool` |
|
||||
| **分叉代理** | 最多 5 turns | 无 turn 限制 |
|
||||
| **转录** | 不记录(`skipTranscript`) | 不记录(`skipTranscript`) |
|
||||
|
||||
### 协作流
|
||||
|
||||
```
|
||||
每次对话结束
|
||||
↓
|
||||
extractMemories → 提取新记忆片段 → 写入 *.md + MEMORY.md
|
||||
↓
|
||||
(积累 24h + 5个会话后)
|
||||
↓
|
||||
autoDream → 回顾所有记忆 + 会话记录
|
||||
↓
|
||||
合并重复 / 修正过时 / 删除矛盾 / 压缩索引
|
||||
↓
|
||||
MEMORY.md 和主题文件焕然一新
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、源码导航
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/services/autoDream/autoDream.ts` | 主逻辑:门控检查、启动分叉代理、进度监控 |
|
||||
| `src/services/autoDream/config.ts` | 开关控制:settings.json 或 GrowthBook |
|
||||
| `src/services/autoDream/consolidationPrompt.ts` | 梦境提示词:4 阶段整合工作流 |
|
||||
| `src/services/autoDream/consolidationLock.ts` | 锁文件机制:防并发、时间戳、回滚 |
|
||||
| `src/tasks/DreamTask/DreamTask.ts` | UI 任务注册:状态管理、终止、回滚 |
|
||||
| `src/tasks/pillLabel.ts` | 底部状态栏标签:"dreaming" |
|
||||
| `src/components/tasks/DreamDetailDialog.tsx` | 梦境详情对话框 UI |
|
||||
| `src/query/stopHooks.ts` | 执行入口:每次回复后触发 |
|
||||
| `src/utils/backgroundHousekeeping.ts` | 初始化入口:`initAutoDream()` |
|
||||
| `src/services/extractMemories/extractMemories.ts` | 共享的 `createAutoMemCanUseTool` |
|
||||
|
||||
---
|
||||
|
||||
## 九、分析事件
|
||||
|
||||
AutoDream 通过以下事件记录运行状态:
|
||||
|
||||
| 事件 | 时机 | 附带数据 |
|
||||
|------|------|----------|
|
||||
| `tengu_auto_dream_fired` | 梦境启动 | `hours_since`, `sessions_since` |
|
||||
| `tengu_auto_dream_completed` | 梦境完成 | `cache_read`, `cache_created`, `output`, `sessions_reviewed` |
|
||||
| `tengu_auto_dream_failed` | 梦境失败 | — |
|
||||
@@ -0,0 +1,288 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html class="light" lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&family=Inter:wght@400;500;600&family=JetBrains+Mono&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
||||
<script id="tailwind-config">
|
||||
tailwind.config = {
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
extend: {
|
||||
"colors": {
|
||||
"primary-fixed": "#ffdbd0",
|
||||
"on-secondary-fixed-variant": "#094a76",
|
||||
"on-primary": "#ffffff",
|
||||
"on-surface": "#1b1c1a",
|
||||
"error": "#ba1a1a",
|
||||
"inverse-on-surface": "#f2f1ed",
|
||||
"surface-container-lowest": "#ffffff",
|
||||
"tertiary-fixed": "#d4eab4",
|
||||
"secondary-container": "#9acbfe",
|
||||
"on-tertiary": "#ffffff",
|
||||
"primary-fixed-dim": "#ffb59d",
|
||||
"secondary-fixed": "#cfe5ff",
|
||||
"surface-container-low": "#f4f4f0",
|
||||
"tertiary": "#4f6237",
|
||||
"surface-dim": "#dbdad6",
|
||||
"outline-variant": "#dac1ba",
|
||||
"on-primary-fixed-variant": "#75331c",
|
||||
"tertiary-container": "#677b4e",
|
||||
"surface-container": "#efeeea",
|
||||
"surface-variant": "#e3e2df",
|
||||
"error-container": "#ffdad6",
|
||||
"on-primary-container": "#fffbff",
|
||||
"inverse-primary": "#ffb59d",
|
||||
"on-error": "#ffffff",
|
||||
"tertiary-fixed-dim": "#b8ce99",
|
||||
"background": "#faf9f5",
|
||||
"on-primary-fixed": "#390c00",
|
||||
"on-secondary": "#ffffff",
|
||||
"surface-tint": "#924a31",
|
||||
"inverse-surface": "#2f312e",
|
||||
"on-secondary-fixed": "#001d34",
|
||||
"primary-container": "#ad5f45",
|
||||
"on-surface-variant": "#54433e",
|
||||
"surface-container-high": "#e9e8e4",
|
||||
"surface": "#faf9f5",
|
||||
"on-tertiary-container": "#faffea",
|
||||
"primary": "#8f482f",
|
||||
"on-tertiary-fixed": "#102000",
|
||||
"surface-container-highest": "#e3e2df",
|
||||
"secondary": "#2d628f",
|
||||
"on-background": "#1b1c1a",
|
||||
"on-secondary-container": "#1e5683",
|
||||
"outline": "#87736d",
|
||||
"surface-bright": "#faf9f5",
|
||||
"on-tertiary-fixed-variant": "#3b4c24",
|
||||
"secondary-fixed-dim": "#9acbfe",
|
||||
"on-error-container": "#93000a"
|
||||
},
|
||||
"borderRadius": {
|
||||
"DEFAULT": "0.25rem",
|
||||
"lg": "0.875rem",
|
||||
"xl": "1rem",
|
||||
"full": "9999px"
|
||||
},
|
||||
"fontFamily": {
|
||||
"headline": ["Manrope"],
|
||||
"body": ["Inter"],
|
||||
"label": ["Inter"],
|
||||
"mono": ["JetBrains Mono"]
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
.font-headline { font-family: 'Manrope', sans-serif; }
|
||||
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #dac1ba; border-radius: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-background text-on-surface selection:bg-primary-fixed">
|
||||
<!-- Sidebar Navigation -->
|
||||
<aside class="fixed left-0 top-0 h-full w-[280px] bg-surface-container-low flex flex-col p-4 gap-2 z-40 hidden md:flex">
|
||||
<div class="flex items-center gap-3 px-2 mb-6">
|
||||
<div class="w-8 h-8 rounded bg-primary flex items-center justify-center text-on-primary">
|
||||
<span class="material-symbols-outlined text-sm">code_blocks</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-bold text-on-surface font-headline leading-tight">All projects</div>
|
||||
<div class="text-[11px] text-outline font-medium">Active Session</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full py-2.5 px-4 mb-4 bg-surface-container-lowest border border-outline-variant/30 rounded-lg text-left text-sm text-outline flex items-center gap-2 hover:bg-white transition-all">
|
||||
<span class="material-symbols-outlined text-sm">search</span>
|
||||
<span>Search sessions</span>
|
||||
</button>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<div class="px-2 py-1 text-[10px] uppercase tracking-widest text-outline/60 font-bold mb-1">Navigation</div>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">add</span>
|
||||
New session
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">calendar_today</span>
|
||||
Scheduled
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium bg-surface-container-lowest text-on-surface rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-primary before:rounded-full" href="#">
|
||||
<span class="material-symbols-outlined text-lg">history</span>
|
||||
Today
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">event_note</span>
|
||||
Previous 7 Days
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">archive</span>
|
||||
Older
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto pt-4 border-t border-outline-variant/20 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-xs font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">computer</span>
|
||||
Local Mode
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-xs font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
|
||||
<span class="material-symbols-outlined text-lg">cloud</span>
|
||||
Remote Mode
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Canvas -->
|
||||
<main class="md:ml-[280px] min-h-screen flex flex-col relative">
|
||||
<!-- Top App Bar -->
|
||||
<header class="sticky top-0 z-30 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 h-12 w-full">
|
||||
<div class="flex items-center gap-6">
|
||||
<span class="text-sm font-bold text-on-surface uppercase tracking-tighter font-headline">Claude Code Companion</span>
|
||||
<nav class="hidden lg:flex items-center gap-4">
|
||||
<a class="text-sm font-semibold text-outline hover:text-primary transition-colors" href="#">Code</a>
|
||||
<a class="text-sm font-semibold text-on-surface border-b-2 border-primary pb-1" href="#">Terminal</a>
|
||||
<a class="text-sm font-semibold text-outline hover:text-primary transition-colors" href="#">History</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="p-1 text-outline hover:text-primary active:opacity-70 transition-colors">
|
||||
<span class="material-symbols-outlined text-lg">arrow_back_ios</span>
|
||||
</button>
|
||||
<button class="p-1 text-outline hover:text-primary active:opacity-70 transition-colors">
|
||||
<span class="material-symbols-outlined text-lg">arrow_forward_ios</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="text-sm font-semibold text-outline hover:text-primary px-2 transition-colors">Settings</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Divider Line -->
|
||||
<div class="bg-surface-container h-[1px] w-full"></div>
|
||||
<!-- Editor Toolbar / Title Area -->
|
||||
<div class="px-8 py-6 flex justify-between items-center max-w-4xl mx-auto w-full">
|
||||
<div class="flex-1">
|
||||
<input class="bg-transparent border-none focus:ring-0 text-2xl font-bold font-headline text-on-surface w-full p-0 leading-tight" type="text" value="Refactor login flow"/>
|
||||
<div class="text-xs text-outline font-medium mt-1">session-active-042 • last updated 2m ago</div>
|
||||
</div>
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-semibold text-sm rounded-lg transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">visibility</span>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<!-- Conversation Stream -->
|
||||
<div class="flex-1 px-8 pb-40 max-w-4xl mx-auto w-full space-y-10">
|
||||
<!-- User Bubble -->
|
||||
<div class="flex justify-end">
|
||||
<div class="max-w-[85%] bg-surface-container px-5 py-4 rounded-2xl rounded-tr-none shadow-sm">
|
||||
<p class="text-sm leading-relaxed text-on-surface-variant font-medium">
|
||||
I need to refactor the login flow in <code class="font-mono bg-surface-container-highest px-1 rounded">auth.ts</code>. Let's move the JWT signing logic to a separate helper and add validation for the user payload.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Assistant Response -->
|
||||
<div class="flex gap-4">
|
||||
<div class="w-8 h-8 shrink-0 rounded-full bg-primary flex items-center justify-center text-on-primary">
|
||||
<span class="material-symbols-outlined text-sm" style="font-variation-settings: 'FILL' 1;">smart_toy</span>
|
||||
</div>
|
||||
<div class="flex-1 space-y-6">
|
||||
<div class="prose prose-sm text-on-surface leading-relaxed max-w-none">
|
||||
<p class="text-sm">Understood. I will begin by analyzing the current implementation of <code class="font-mono text-secondary">auth.ts</code>. I'll extract the JWT logic into a new utility function and implement the validation layer as requested.</p>
|
||||
</div>
|
||||
<!-- Collapsed Thinking Block -->
|
||||
<div class="bg-surface-container-low border border-outline-variant/20 rounded-xl overflow-hidden">
|
||||
<button class="w-full px-4 py-2.5 flex items-center justify-between text-xs font-semibold text-outline hover:bg-surface-container-high transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-sm">psychology</span>
|
||||
<span>Thinking Process</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-sm">expand_more</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tool Use Block -->
|
||||
<div class="bg-surface-dim rounded-xl p-4 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-secondary text-base">edit_document</span>
|
||||
<span class="text-xs font-mono font-bold text-on-surface">edit_file: auth.ts</span>
|
||||
</div>
|
||||
<span class="text-[10px] text-outline font-bold uppercase tracking-widest">Modified</span>
|
||||
</div>
|
||||
<div class="bg-surface-container-lowest/50 rounded-lg p-3 font-mono text-xs text-on-surface-variant border border-outline-variant/10">
|
||||
<span class="text-tertiary">+ const validatePayload = (user) => { ... }</span><br/>
|
||||
<span class="text-tertiary">+ export const signToken = (payload) => jwt.sign(payload, SECRET);</span><br/>
|
||||
<span class="opacity-50">- // OLD LOGIC REMOVED</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Crafting Status Bar (Internal to Stream) -->
|
||||
<div class="flex items-center justify-between bg-surface-container-lowest border border-primary/20 rounded-full px-4 py-2 shadow-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
|
||||
<span class="text-xs font-bold font-headline text-primary">Crafting...</span>
|
||||
<span class="w-[1px] h-3 bg-outline-variant/50"></span>
|
||||
<span class="text-xs font-mono text-outline">00:42</span>
|
||||
<span class="text-xs font-mono text-outline/60">1.2k tokens</span>
|
||||
</div>
|
||||
<button class="flex items-center gap-1.5 px-3 py-1 bg-error-container text-on-error-container text-[10px] font-bold rounded-full hover:bg-error/10 transition-colors">
|
||||
<span class="material-symbols-outlined text-xs">stop</span>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chat Composer (Floating Bottom) -->
|
||||
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 md:left-[calc(50%+140px)] w-full max-w-2xl px-6 z-40">
|
||||
<div class="bg-surface-container-lowest/85 backdrop-blur-xl border border-outline-variant/15 rounded-2xl shadow-lg p-3">
|
||||
<div class="relative flex items-end gap-3">
|
||||
<button class="p-2 text-outline hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
</button>
|
||||
<textarea class="w-full bg-transparent border-none focus:ring-0 resize-none py-2 text-sm text-on-surface placeholder:text-outline/50 min-h-[44px] max-h-48" placeholder="Ask Claude to edit, debug or explain..." rows="1"></textarea>
|
||||
<button class="w-10 h-10 rounded-xl bg-primary text-on-primary flex items-center justify-center hover:shadow-md transition-all active:scale-95">
|
||||
<span class="material-symbols-outlined">arrow_upward</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 px-2 mt-2">
|
||||
<button class="flex items-center gap-1.5 text-[10px] font-bold text-outline uppercase tracking-wider hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">terminal</span>
|
||||
Terminal
|
||||
</button>
|
||||
<button class="flex items-center gap-1.5 text-[10px] font-bold text-outline uppercase tracking-wider hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">image</span>
|
||||
Add Vision
|
||||
</button>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="text-[10px] text-outline/50 font-medium">Claude 3.5 Sonnet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Global Footer Bar -->
|
||||
<footer class="fixed bottom-0 left-0 w-full h-8 bg-background border-t border-outline-variant/20 flex items-center justify-between px-4 z-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-[10px] font-medium text-outline uppercase tracking-tighter">User Avatar • username • Pro Plan</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="flex items-center gap-1 text-[10px] text-outline hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-xs">account_tree</span>
|
||||
main-branch
|
||||
</button>
|
||||
<button class="flex items-center gap-1 text-[10px] text-outline hover:text-primary transition-colors">
|
||||
<span class="material-symbols-outlined text-xs">layers</span>
|
||||
worktree-toggle
|
||||
</button>
|
||||
<button class="flex items-center gap-1 text-[10px] text-primary font-bold transition-colors">
|
||||
<span class="material-symbols-outlined text-xs">sync_alt</span>
|
||||
local-switch
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</body></html>
|
||||
@@ -0,0 +1,35 @@
|
||||
# Claude Code Haha v0.1.7
|
||||
|
||||
这是一个以 Windows 桌面端在线更新稳定性和聊天展示体验修复为主的补丁版本。
|
||||
|
||||
相比 `v0.1.6`,这次重点修复了 Windows 桌面端自动更新后本地服务启动失败的问题,并补齐了 Windows 开发环境 WebUI 启动 CLI 时的宏预加载逻辑。同时修复了 Mermaid 渲染错误提示和聊天手动滚动被打断的问题。
|
||||
|
||||
## 主要更新
|
||||
|
||||
- 修复 Windows 桌面端在线自动更新流程:安装新版本前会先停止本地 server sidecar 和 adapter sidecar,避免升级后重启时出现 `os error 32`、`另一个程序正在使用此文件` 或“本地服务启动失败”。
|
||||
- Windows 发布产物改为优先使用 NSIS 安装包,并在安装器 hook 中处理 sidecar 进程清理,降低从旧版本自动升级时被文件锁卡住的概率。
|
||||
- 修复 Windows 开发环境中 WebUI 启动 CLI 失败的问题,源码 fallback 启动 CLI 时会正确加载 `preload.ts`,避免 `MACRO is not defined` 导致进程启动失败。
|
||||
- 优化聊天列表手动滚动体验:用户向上查看历史消息时,流式输出不会强制把视图拉回底部;当用户仍停留在底部附近时,会继续保持自动跟随最新回复。
|
||||
|
||||
## 问题修复
|
||||
|
||||
- 修复 Mermaid 图表渲染失败时可能出现开发错误覆盖层的问题,让错误状态保持在聊天消息内部展示。
|
||||
- 修复聊天流式输出期间手动滚动位置被覆盖的问题,并补充相关回归测试。
|
||||
- 修复 Windows updater 安装阶段没有提前释放 sidecar 文件句柄的问题。
|
||||
- 修复 Windows dev WebUI 会话启动时 CLI 进程直接以 code 1 退出的问题。
|
||||
|
||||
## 其他说明
|
||||
|
||||
- 已安装 `v0.1.5` 或 `v0.1.6` 的 Windows 用户正常情况下可以直接升级到 `v0.1.7`,不需要先卸载旧版本。
|
||||
- 如果某台机器已经卡在旧版本升级后的“本地服务启动失败”页面,可以先完全退出桌面端和残留的 `claude-sidecar.exe` 进程,再安装 `v0.1.7`。
|
||||
- GitHub Release 正文继续以 `release-notes/v0.1.7.md` 作为来源,发布时无需再手动复制 Markdown。
|
||||
|
||||
## 安装说明
|
||||
|
||||
### macOS
|
||||
|
||||
首次打开如果提示“已损坏”或“无法验证开发者”,请执行:
|
||||
|
||||
```bash
|
||||
xattr -cr /Applications/Claude\ Code\ Haha.app
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
|
||||
import type { ReplBridgeHandle } from './replBridge.js'
|
||||
import { toCompatSessionId } from './sessionIdCompat.js'
|
||||
|
||||
/**
|
||||
* Global pointer to the active REPL bridge handle, so callers outside
|
||||
* useReplBridge's React tree (tools, slash commands) can invoke handle methods
|
||||
* like subscribePR. Same one-bridge-per-process justification as bridgeDebug.ts
|
||||
* — the handle's closure captures the sessionId and getAccessToken that created
|
||||
* the session, and re-deriving those independently (BriefTool/upload.ts pattern)
|
||||
* risks staging/prod token divergence.
|
||||
*
|
||||
* Set from useReplBridge.tsx when init completes; cleared on teardown.
|
||||
*/
|
||||
|
||||
let handle: ReplBridgeHandle | null = null
|
||||
|
||||
export function setReplBridgeHandle(h: ReplBridgeHandle | null): void {
|
||||
handle = h
|
||||
// Publish (or clear) our bridge session ID in the session record so other
|
||||
// local peers can dedup us out of their bridge list — local is preferred.
|
||||
void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {})
|
||||
}
|
||||
|
||||
export function getReplBridgeHandle(): ReplBridgeHandle | null {
|
||||
return handle
|
||||
}
|
||||
|
||||
/**
|
||||
* Our own bridge session ID in the session_* compat format the API returns
|
||||
* in /v1/sessions responses — or undefined if bridge isn't connected.
|
||||
*/
|
||||
export function getSelfBridgeCompatId(): string | undefined {
|
||||
const h = getReplBridgeHandle()
|
||||
return h ? toCompatSessionId(h.bridgeSessionId) : undefined
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import { type ChildProcess, spawn } from 'child_process'
|
||||
import { createWriteStream, type WriteStream } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { dirname, join } from 'path'
|
||||
import { createInterface } from 'readline'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
import { debugTruncate } from './debugUtils.js'
|
||||
import type {
|
||||
SessionActivity,
|
||||
SessionDoneStatus,
|
||||
SessionHandle,
|
||||
SessionSpawner,
|
||||
SessionSpawnOpts,
|
||||
} from './types.js'
|
||||
|
||||
const MAX_ACTIVITIES = 10
|
||||
const MAX_STDERR_LINES = 10
|
||||
|
||||
/**
|
||||
* Sanitize a session ID for use in file names.
|
||||
* Strips any characters that could cause path traversal (e.g. `../`, `/`)
|
||||
* or other filesystem issues, replacing them with underscores.
|
||||
*/
|
||||
export function safeFilenameId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* A control_request emitted by the child CLI when it needs permission to
|
||||
* execute a **specific** tool invocation (not a general capability check).
|
||||
* The bridge forwards this to the server so the user can approve/deny.
|
||||
*/
|
||||
export type PermissionRequest = {
|
||||
type: 'control_request'
|
||||
request_id: string
|
||||
request: {
|
||||
/** Per-invocation permission check — "may I run this tool with these inputs?" */
|
||||
subtype: 'can_use_tool'
|
||||
tool_name: string
|
||||
input: Record<string, unknown>
|
||||
tool_use_id: string
|
||||
}
|
||||
}
|
||||
|
||||
type SessionSpawnerDeps = {
|
||||
execPath: string
|
||||
/**
|
||||
* Arguments that must precede the CLI flags when spawning. Empty for
|
||||
* compiled binaries (where execPath is the claude binary itself); contains
|
||||
* the script path (process.argv[1]) for npm installs where execPath is the
|
||||
* node runtime. Without this, node sees --sdk-url as a node option and
|
||||
* exits with "bad option: --sdk-url" (see anthropics/claude-code#28334).
|
||||
*/
|
||||
scriptArgs: string[]
|
||||
env: NodeJS.ProcessEnv
|
||||
verbose: boolean
|
||||
sandbox: boolean
|
||||
debugFile?: string
|
||||
permissionMode?: string
|
||||
onDebug: (msg: string) => void
|
||||
onActivity?: (sessionId: string, activity: SessionActivity) => void
|
||||
onPermissionRequest?: (
|
||||
sessionId: string,
|
||||
request: PermissionRequest,
|
||||
accessToken: string,
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Map tool names to human-readable verbs for the status display. */
|
||||
const TOOL_VERBS: Record<string, string> = {
|
||||
Read: 'Reading',
|
||||
Write: 'Writing',
|
||||
Edit: 'Editing',
|
||||
MultiEdit: 'Editing',
|
||||
Bash: 'Running',
|
||||
Glob: 'Searching',
|
||||
Grep: 'Searching',
|
||||
WebFetch: 'Fetching',
|
||||
WebSearch: 'Searching',
|
||||
Task: 'Running task',
|
||||
FileReadTool: 'Reading',
|
||||
FileWriteTool: 'Writing',
|
||||
FileEditTool: 'Editing',
|
||||
GlobTool: 'Searching',
|
||||
GrepTool: 'Searching',
|
||||
BashTool: 'Running',
|
||||
NotebookEditTool: 'Editing notebook',
|
||||
LSP: 'LSP',
|
||||
}
|
||||
|
||||
function toolSummary(name: string, input: Record<string, unknown>): string {
|
||||
const verb = TOOL_VERBS[name] ?? name
|
||||
const target =
|
||||
(input.file_path as string) ??
|
||||
(input.filePath as string) ??
|
||||
(input.pattern as string) ??
|
||||
(input.command as string | undefined)?.slice(0, 60) ??
|
||||
(input.url as string) ??
|
||||
(input.query as string) ??
|
||||
''
|
||||
if (target) {
|
||||
return `${verb} ${target}`
|
||||
}
|
||||
return verb
|
||||
}
|
||||
|
||||
function extractActivities(
|
||||
line: string,
|
||||
sessionId: string,
|
||||
onDebug: (msg: string) => void,
|
||||
): SessionActivity[] {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = jsonParse(line)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const msg = parsed as Record<string, unknown>
|
||||
const activities: SessionActivity[] = []
|
||||
const now = Date.now()
|
||||
|
||||
switch (msg.type) {
|
||||
case 'assistant': {
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
if (!message) break
|
||||
const content = message.content
|
||||
if (!Array.isArray(content)) break
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
const b = block as Record<string, unknown>
|
||||
|
||||
if (b.type === 'tool_use') {
|
||||
const name = (b.name as string) ?? 'Tool'
|
||||
const input = (b.input as Record<string, unknown>) ?? {}
|
||||
const summary = toolSummary(name, input)
|
||||
activities.push({
|
||||
type: 'tool_start',
|
||||
summary,
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
|
||||
)
|
||||
} else if (b.type === 'text') {
|
||||
const text = (b.text as string) ?? ''
|
||||
if (text.length > 0) {
|
||||
activities.push({
|
||||
type: 'text',
|
||||
summary: text.slice(0, 80),
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'result': {
|
||||
const subtype = msg.subtype as string | undefined
|
||||
if (subtype === 'success') {
|
||||
activities.push({
|
||||
type: 'result',
|
||||
summary: 'Session completed',
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
|
||||
)
|
||||
} else if (subtype) {
|
||||
const errors = msg.errors as string[] | undefined
|
||||
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
|
||||
activities.push({
|
||||
type: 'error',
|
||||
summary: errorSummary,
|
||||
timestamp: now,
|
||||
})
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
|
||||
)
|
||||
} else {
|
||||
onDebug(
|
||||
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return activities
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from a replayed SDKUserMessage NDJSON line. Returns the
|
||||
* trimmed text if this looks like a real human-authored message, otherwise
|
||||
* undefined so the caller keeps waiting for the first real message.
|
||||
*/
|
||||
function extractUserMessageText(
|
||||
msg: Record<string, unknown>,
|
||||
): string | undefined {
|
||||
// Skip tool-result user messages (wrapped subagent results) and synthetic
|
||||
// caveat messages — neither is human-authored.
|
||||
if (msg.parent_tool_use_id != null || msg.isSynthetic || msg.isReplay)
|
||||
return undefined
|
||||
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
const content = message?.content
|
||||
let text: string | undefined
|
||||
if (typeof content === 'string') {
|
||||
text = content
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === 'object' &&
|
||||
(block as Record<string, unknown>).type === 'text'
|
||||
) {
|
||||
text = (block as Record<string, unknown>).text as string | undefined
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
text = text?.trim()
|
||||
return text ? text : undefined
|
||||
}
|
||||
|
||||
/** Build a short preview of tool input for debug logging. */
|
||||
function inputPreview(input: Record<string, unknown>): string {
|
||||
const parts: string[] = []
|
||||
for (const [key, val] of Object.entries(input)) {
|
||||
if (typeof val === 'string') {
|
||||
parts.push(`${key}="${val.slice(0, 100)}"`)
|
||||
}
|
||||
if (parts.length >= 3) break
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
||||
return {
|
||||
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
|
||||
// Debug file resolution:
|
||||
// 1. If deps.debugFile is provided, use it with session ID suffix for uniqueness
|
||||
// 2. If verbose or ant build, auto-generate a temp file path
|
||||
// 3. Otherwise, no debug file
|
||||
const safeId = safeFilenameId(opts.sessionId)
|
||||
let debugFile: string | undefined
|
||||
if (deps.debugFile) {
|
||||
const ext = deps.debugFile.lastIndexOf('.')
|
||||
if (ext > 0) {
|
||||
debugFile = `${deps.debugFile.slice(0, ext)}-${safeId}${deps.debugFile.slice(ext)}`
|
||||
} else {
|
||||
debugFile = `${deps.debugFile}-${safeId}`
|
||||
}
|
||||
} else if (deps.verbose || process.env.USER_TYPE === 'ant') {
|
||||
debugFile = join(tmpdir(), 'claude', `bridge-session-${safeId}.log`)
|
||||
}
|
||||
|
||||
// Transcript file: write raw NDJSON lines for post-hoc analysis.
|
||||
// Placed alongside the debug file when one is configured.
|
||||
let transcriptStream: WriteStream | null = null
|
||||
let transcriptPath: string | undefined
|
||||
if (deps.debugFile) {
|
||||
transcriptPath = join(
|
||||
dirname(deps.debugFile),
|
||||
`bridge-transcript-${safeId}.jsonl`,
|
||||
)
|
||||
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
|
||||
transcriptStream.on('error', err => {
|
||||
deps.onDebug(
|
||||
`[bridge:session] Transcript write error: ${err.message}`,
|
||||
)
|
||||
transcriptStream = null
|
||||
})
|
||||
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
|
||||
}
|
||||
|
||||
const args = [
|
||||
...deps.scriptArgs,
|
||||
'--print',
|
||||
'--sdk-url',
|
||||
opts.sdkUrl,
|
||||
'--session-id',
|
||||
opts.sessionId,
|
||||
'--input-format',
|
||||
'stream-json',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--replay-user-messages',
|
||||
...(deps.verbose ? ['--verbose'] : []),
|
||||
...(debugFile ? ['--debug-file', debugFile] : []),
|
||||
...(deps.permissionMode
|
||||
? ['--permission-mode', deps.permissionMode]
|
||||
: []),
|
||||
]
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...deps.env,
|
||||
// Strip the bridge's OAuth token so the child CC process uses
|
||||
// the session access token for inference instead.
|
||||
CLAUDE_CODE_OAUTH_TOKEN: undefined,
|
||||
CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
|
||||
...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
|
||||
CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
|
||||
// v1: HybridTransport (WS reads + POST writes) to Session-Ingress.
|
||||
// Harmless in v2 mode — transportUtils checks CLAUDE_CODE_USE_CCR_V2 first.
|
||||
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1',
|
||||
// v2: SSETransport + CCRClient to CCR's /v1/code/sessions/* endpoints.
|
||||
// Same env vars environment-manager sets in the container path.
|
||||
...(opts.useCcrV2 && {
|
||||
CLAUDE_CODE_USE_CCR_V2: '1',
|
||||
CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
|
||||
}),
|
||||
}
|
||||
|
||||
deps.onDebug(
|
||||
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,
|
||||
)
|
||||
deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`)
|
||||
if (debugFile) {
|
||||
deps.onDebug(`[bridge:session] Debug log: ${debugFile}`)
|
||||
}
|
||||
|
||||
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
|
||||
// stderr for error capture and diagnostics.
|
||||
const child: ChildProcess = spawn(deps.execPath, args, {
|
||||
cwd: dir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
|
||||
const activities: SessionActivity[] = []
|
||||
let currentActivity: SessionActivity | null = null
|
||||
const lastStderr: string[] = []
|
||||
let sigkillSent = false
|
||||
let firstUserMessageSeen = false
|
||||
|
||||
// Buffer stderr for error diagnostics
|
||||
if (child.stderr) {
|
||||
const stderrRl = createInterface({ input: child.stderr })
|
||||
stderrRl.on('line', line => {
|
||||
// Forward stderr to bridge's stderr in verbose mode
|
||||
if (deps.verbose) {
|
||||
process.stderr.write(line + '\n')
|
||||
}
|
||||
// Ring buffer of last N lines
|
||||
if (lastStderr.length >= MAX_STDERR_LINES) {
|
||||
lastStderr.shift()
|
||||
}
|
||||
lastStderr.push(line)
|
||||
})
|
||||
}
|
||||
|
||||
// Parse NDJSON from child stdout
|
||||
if (child.stdout) {
|
||||
const rl = createInterface({ input: child.stdout })
|
||||
rl.on('line', line => {
|
||||
// Write raw NDJSON to transcript file
|
||||
if (transcriptStream) {
|
||||
transcriptStream.write(line + '\n')
|
||||
}
|
||||
|
||||
// Log all messages flowing from the child CLI to the bridge
|
||||
deps.onDebug(
|
||||
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
|
||||
)
|
||||
|
||||
// In verbose mode, forward raw output to stderr
|
||||
if (deps.verbose) {
|
||||
process.stderr.write(line + '\n')
|
||||
}
|
||||
|
||||
const extracted = extractActivities(
|
||||
line,
|
||||
opts.sessionId,
|
||||
deps.onDebug,
|
||||
)
|
||||
for (const activity of extracted) {
|
||||
// Maintain ring buffer
|
||||
if (activities.length >= MAX_ACTIVITIES) {
|
||||
activities.shift()
|
||||
}
|
||||
activities.push(activity)
|
||||
currentActivity = activity
|
||||
|
||||
deps.onActivity?.(opts.sessionId, activity)
|
||||
}
|
||||
|
||||
// Detect control_request and replayed user messages.
|
||||
// extractActivities parses the same line but swallows parse errors
|
||||
// and skips 'user' type — re-parse here is cheap (NDJSON lines are
|
||||
// small) and keeps each path self-contained.
|
||||
{
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = jsonParse(line)
|
||||
} catch {
|
||||
// Non-JSON line, skip detection
|
||||
}
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const msg = parsed as Record<string, unknown>
|
||||
|
||||
if (msg.type === 'control_request') {
|
||||
const request = msg.request as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (
|
||||
request?.subtype === 'can_use_tool' &&
|
||||
deps.onPermissionRequest
|
||||
) {
|
||||
deps.onPermissionRequest(
|
||||
opts.sessionId,
|
||||
parsed as PermissionRequest,
|
||||
opts.accessToken,
|
||||
)
|
||||
}
|
||||
// interrupt is turn-level; the child handles it internally (print.ts)
|
||||
} else if (
|
||||
msg.type === 'user' &&
|
||||
!firstUserMessageSeen &&
|
||||
opts.onFirstUserMessage
|
||||
) {
|
||||
const text = extractUserMessageText(msg)
|
||||
if (text) {
|
||||
firstUserMessageSeen = true
|
||||
opts.onFirstUserMessage(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const done = new Promise<SessionDoneStatus>(resolve => {
|
||||
child.on('close', (code, signal) => {
|
||||
// Close transcript stream on exit
|
||||
if (transcriptStream) {
|
||||
transcriptStream.end()
|
||||
transcriptStream = null
|
||||
}
|
||||
|
||||
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
|
||||
)
|
||||
resolve('interrupted')
|
||||
} else if (code === 0) {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
|
||||
)
|
||||
resolve('completed')
|
||||
} else {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
|
||||
)
|
||||
resolve('failed')
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
deps.onDebug(
|
||||
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
|
||||
)
|
||||
resolve('failed')
|
||||
})
|
||||
})
|
||||
|
||||
const handle: SessionHandle = {
|
||||
sessionId: opts.sessionId,
|
||||
done,
|
||||
activities,
|
||||
accessToken: opts.accessToken,
|
||||
lastStderr,
|
||||
get currentActivity(): SessionActivity | null {
|
||||
return currentActivity
|
||||
},
|
||||
kill(): void {
|
||||
if (!child.killed) {
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
// On Windows, child.kill('SIGTERM') throws; use default signal.
|
||||
if (process.platform === 'win32') {
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
},
|
||||
forceKill(): void {
|
||||
// Use separate flag because child.killed is set when kill() is called,
|
||||
// not when the process exits. We need to send SIGKILL even after SIGTERM.
|
||||
if (!sigkillSent && child.pid) {
|
||||
sigkillSent = true
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
|
||||
)
|
||||
if (process.platform === 'win32') {
|
||||
child.kill()
|
||||
} else {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
}
|
||||
},
|
||||
writeStdin(data: string): void {
|
||||
if (child.stdin && !child.stdin.destroyed) {
|
||||
deps.onDebug(
|
||||
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
|
||||
)
|
||||
child.stdin.write(data)
|
||||
}
|
||||
},
|
||||
updateAccessToken(token: string): void {
|
||||
handle.accessToken = token
|
||||
// Send the fresh token to the child process via stdin. The child's
|
||||
// StructuredIO handles update_environment_variables messages by
|
||||
// setting process.env directly, so getSessionIngressAuthToken()
|
||||
// picks up the new token on the next refreshHeaders call.
|
||||
handle.writeStdin(
|
||||
jsonStringify({
|
||||
type: 'update_environment_variables',
|
||||
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
|
||||
}) + '\n',
|
||||
)
|
||||
deps.onDebug(
|
||||
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
return handle
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { extractActivities as _extractActivitiesForTesting }
|
||||
@@ -0,0 +1 @@
|
||||
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const compact = {
|
||||
type: 'local',
|
||||
name: 'compact',
|
||||
description:
|
||||
'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_COMPACT),
|
||||
supportsNonInteractive: true,
|
||||
argumentHint: '<optional custom summarization instructions>',
|
||||
load: () => import('./compact.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default compact
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
export default {
|
||||
type: 'local-jsx',
|
||||
name: 'diff',
|
||||
description: 'View uncommitted changes and per-turn diffs',
|
||||
load: () => import('./diff.js'),
|
||||
} satisfies Command
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
const installGitHubApp = {
|
||||
type: 'local-jsx',
|
||||
name: 'install-github-app',
|
||||
description: 'Set up Claude GitHub Actions for a repository',
|
||||
availability: ['claude-ai', 'console'],
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_INSTALL_GITHUB_APP_COMMAND),
|
||||
load: () => import('./install-github-app.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default installGitHubApp
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import { hasAnthropicApiKeyAuth } from '../../utils/auth.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
export default () =>
|
||||
({
|
||||
type: 'local-jsx',
|
||||
name: 'login',
|
||||
description: hasAnthropicApiKeyAuth()
|
||||
? 'Switch Anthropic accounts'
|
||||
: 'Sign in with your Anthropic account',
|
||||
isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGIN_COMMAND),
|
||||
load: () => import('./login.js'),
|
||||
}) satisfies Command
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
|
||||
const mobile = {
|
||||
type: 'local-jsx',
|
||||
name: 'mobile',
|
||||
aliases: ['ios', 'android'],
|
||||
description: 'Show QR code to download the Claude mobile app',
|
||||
load: () => import('./mobile.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default mobile
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { Passes } from '../../components/Passes/Passes.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { getCachedRemainingPasses } from '../../services/api/referral.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||||
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
|
||||
// Mark that user has visited /passes so we stop showing the upsell
|
||||
const config = getGlobalConfig();
|
||||
const isFirstVisit = !config.hasVisitedPasses;
|
||||
if (isFirstVisit) {
|
||||
const remaining = getCachedRemainingPasses();
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasVisitedPasses: true,
|
||||
passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining
|
||||
}));
|
||||
}
|
||||
logEvent('tengu_guest_passes_visited', {
|
||||
is_first_visit: isFirstVisit
|
||||
});
|
||||
return <Passes onDone={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlBhc3NlcyIsImxvZ0V2ZW50IiwiZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiZ2V0R2xvYmFsQ29uZmlnIiwic2F2ZUdsb2JhbENvbmZpZyIsImNhbGwiLCJvbkRvbmUiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwiY29uZmlnIiwiaXNGaXJzdFZpc2l0IiwiaGFzVmlzaXRlZFBhc3NlcyIsInJlbWFpbmluZyIsImN1cnJlbnQiLCJwYXNzZXNMYXN0U2VlblJlbWFpbmluZyIsImlzX2ZpcnN0X3Zpc2l0Il0sInNvdXJjZXMiOlsicGFzc2VzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFBhc3NlcyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvUGFzc2VzL1Bhc3Nlcy5qcydcbmltcG9ydCB7IGxvZ0V2ZW50IH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYXBpL3JlZmVycmFsLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHsgZ2V0R2xvYmFsQ29uZmlnLCBzYXZlR2xvYmFsQ29uZmlnIH0gZnJvbSAnLi4vLi4vdXRpbHMvY29uZmlnLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICAvLyBNYXJrIHRoYXQgdXNlciBoYXMgdmlzaXRlZCAvcGFzc2VzIHNvIHdlIHN0b3Agc2hvd2luZyB0aGUgdXBzZWxsXG4gIGNvbnN0IGNvbmZpZyA9IGdldEdsb2JhbENvbmZpZygpXG4gIGNvbnN0IGlzRmlyc3RWaXNpdCA9ICFjb25maWcuaGFzVmlzaXRlZFBhc3Nlc1xuICBpZiAoaXNGaXJzdFZpc2l0KSB7XG4gICAgY29uc3QgcmVtYWluaW5nID0gZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzKClcbiAgICBzYXZlR2xvYmFsQ29uZmlnKGN1cnJlbnQgPT4gKHtcbiAgICAgIC4uLmN1cnJlbnQsXG4gICAgICBoYXNWaXNpdGVkUGFzc2VzOiB0cnVlLFxuICAgICAgcGFzc2VzTGFzdFNlZW5SZW1haW5pbmc6IHJlbWFpbmluZyA/PyBjdXJyZW50LnBhc3Nlc0xhc3RTZWVuUmVtYWluaW5nLFxuICAgIH0pKVxuICB9XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ndWVzdF9wYXNzZXNfdmlzaXRlZCcsIHsgaXNfZmlyc3RfdmlzaXQ6IGlzRmlyc3RWaXNpdCB9KVxuICByZXR1cm4gPFBhc3NlcyBvbkRvbmU9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxNQUFNLFFBQVEsbUNBQW1DO0FBQzFELFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0Msd0JBQXdCLFFBQVEsZ0NBQWdDO0FBQ3pFLGNBQWNDLHFCQUFxQixRQUFRLHdCQUF3QjtBQUNuRSxTQUFTQyxlQUFlLEVBQUVDLGdCQUFnQixRQUFRLHVCQUF1QjtBQUV6RSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUVKLHFCQUFxQixDQUM5QixFQUFFSyxPQUFPLENBQUNULEtBQUssQ0FBQ1UsU0FBUyxDQUFDLENBQUM7RUFDMUI7RUFDQSxNQUFNQyxNQUFNLEdBQUdOLGVBQWUsQ0FBQyxDQUFDO0VBQ2hDLE1BQU1PLFlBQVksR0FBRyxDQUFDRCxNQUFNLENBQUNFLGdCQUFnQjtFQUM3QyxJQUFJRCxZQUFZLEVBQUU7SUFDaEIsTUFBTUUsU0FBUyxHQUFHWCx3QkFBd0IsQ0FBQyxDQUFDO0lBQzVDRyxnQkFBZ0IsQ0FBQ1MsT0FBTyxLQUFLO01BQzNCLEdBQUdBLE9BQU87TUFDVkYsZ0JBQWdCLEVBQUUsSUFBSTtNQUN0QkcsdUJBQXVCLEVBQUVGLFNBQVMsSUFBSUMsT0FBTyxDQUFDQztJQUNoRCxDQUFDLENBQUMsQ0FBQztFQUNMO0VBQ0FkLFFBQVEsQ0FBQyw0QkFBNEIsRUFBRTtJQUFFZSxjQUFjLEVBQUVMO0VBQWEsQ0FBQyxDQUFDO0VBQ3hFLE9BQU8sQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUNKLE1BQU0sQ0FBQyxHQUFHO0FBQ25DIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,34 @@
|
||||
// @generated stub from scan-missing-imports
|
||||
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
|
||||
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
|
||||
// bun build resolver 的占位符。
|
||||
const __target = function noop() {}
|
||||
const __handler: ProxyHandler<any> = {
|
||||
get(_t, prop) {
|
||||
if (prop === '__esModule') return true
|
||||
if (prop === 'default') return new Proxy(__target, __handler)
|
||||
if (prop === Symbol.toPrimitive) return () => undefined
|
||||
if (prop === Symbol.iterator) return function* () {}
|
||||
if (prop === Symbol.asyncIterator) return async function* () {}
|
||||
if (prop === 'then') return undefined
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
apply() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
construct() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
}
|
||||
const stub: any = new Proxy(__target, __handler)
|
||||
export default stub
|
||||
export const __stubMissing = true
|
||||
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
|
||||
export const createCachedMCState = stub
|
||||
export const isCachedMicrocompactEnabled = stub
|
||||
export const isModelSupportedForCacheEditing = stub
|
||||
export const getCachedMCConfig = stub
|
||||
export const markToolsSentToAPI = stub
|
||||
export const resetCachedMCState = stub
|
||||
export const checkProtectedNamespace = stub
|
||||
export const getCoordinatorUserContext = stub
|
||||
@@ -0,0 +1,34 @@
|
||||
// @generated stub from scan-missing-imports
|
||||
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
|
||||
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
|
||||
// bun build resolver 的占位符。
|
||||
const __target = function noop() {}
|
||||
const __handler: ProxyHandler<any> = {
|
||||
get(_t, prop) {
|
||||
if (prop === '__esModule') return true
|
||||
if (prop === 'default') return new Proxy(__target, __handler)
|
||||
if (prop === Symbol.toPrimitive) return () => undefined
|
||||
if (prop === Symbol.iterator) return function* () {}
|
||||
if (prop === Symbol.asyncIterator) return async function* () {}
|
||||
if (prop === 'then') return undefined
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
apply() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
construct() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
}
|
||||
const stub: any = new Proxy(__target, __handler)
|
||||
export default stub
|
||||
export const __stubMissing = true
|
||||
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
|
||||
export const createCachedMCState = stub
|
||||
export const isCachedMicrocompactEnabled = stub
|
||||
export const isModelSupportedForCacheEditing = stub
|
||||
export const getCachedMCConfig = stub
|
||||
export const markToolsSentToAPI = stub
|
||||
export const resetCachedMCState = stub
|
||||
export const checkProtectedNamespace = stub
|
||||
export const getCoordinatorUserContext = stub
|
||||
@@ -0,0 +1,67 @@
|
||||
import { queryHaiku } from '../../services/api/claude.js'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
import { safeParseJSON } from '../../utils/json.js'
|
||||
import { extractTextContent } from '../../utils/messages.js'
|
||||
import { extractConversationText } from '../../utils/sessionTitle.js'
|
||||
import { asSystemPrompt } from '../../utils/systemPromptType.js'
|
||||
|
||||
export async function generateSessionName(
|
||||
messages: Message[],
|
||||
signal: AbortSignal,
|
||||
): Promise<string | null> {
|
||||
const conversationText = extractConversationText(messages)
|
||||
if (!conversationText) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await queryHaiku({
|
||||
systemPrompt: asSystemPrompt([
|
||||
'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client", "debug-test-failures". Return JSON with a "name" field.',
|
||||
]),
|
||||
userPrompt: conversationText,
|
||||
outputFormat: {
|
||||
type: 'json_schema',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
signal,
|
||||
options: {
|
||||
querySource: 'rename_generate_name',
|
||||
agents: [],
|
||||
isNonInteractiveSession: false,
|
||||
hasAppendSystemPrompt: false,
|
||||
mcpTools: [],
|
||||
},
|
||||
})
|
||||
|
||||
const content = extractTextContent(result.message.content)
|
||||
|
||||
const response = safeParseJSON(content)
|
||||
if (
|
||||
response &&
|
||||
typeof response === 'object' &&
|
||||
'name' in response &&
|
||||
typeof (response as { name: unknown }).name === 'string'
|
||||
) {
|
||||
return (response as { name: string }).name
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
// Haiku timeout/rate-limit/network are expected operational failures —
|
||||
// logForDebugging, not logError. Called automatically on every 3rd bridge
|
||||
// message (initReplBridge.ts), so errors here would flood the error file.
|
||||
logForDebugging(`generateSessionName failed: ${errorMessage(error)}`, {
|
||||
level: 'error',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import {
|
||||
getBridgeBaseUrlOverride,
|
||||
getBridgeTokenOverride,
|
||||
} from '../../bridge/bridgeConfig.js'
|
||||
import type { ToolUseContext } from '../../Tool.js'
|
||||
import type {
|
||||
LocalJSXCommandContext,
|
||||
LocalJSXCommandOnDone,
|
||||
} from '../../types/command.js'
|
||||
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
|
||||
import {
|
||||
getTranscriptPath,
|
||||
saveAgentName,
|
||||
saveCustomTitle,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { isTeammate } from '../../utils/teammate.js'
|
||||
import { generateSessionName } from './generateSessionName.js'
|
||||
|
||||
export async function call(
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
context: ToolUseContext & LocalJSXCommandContext,
|
||||
args: string,
|
||||
): Promise<null> {
|
||||
// Prevent teammates from renaming - their names are set by team leader
|
||||
if (isTeammate()) {
|
||||
onDone(
|
||||
'Cannot rename: This session is a swarm teammate. Teammate names are set by the team leader.',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
let newName: string
|
||||
if (!args || args.trim() === '') {
|
||||
const generated = await generateSessionName(
|
||||
getMessagesAfterCompactBoundary(context.messages),
|
||||
context.abortController.signal,
|
||||
)
|
||||
if (!generated) {
|
||||
onDone(
|
||||
'Could not generate a name: no conversation context yet. Usage: /rename <name>',
|
||||
{ display: 'system' },
|
||||
)
|
||||
return null
|
||||
}
|
||||
newName = generated
|
||||
} else {
|
||||
newName = args.trim()
|
||||
}
|
||||
|
||||
const sessionId = getSessionId() as UUID
|
||||
const fullPath = getTranscriptPath()
|
||||
|
||||
// Always save the custom title (session name)
|
||||
await saveCustomTitle(sessionId, newName, fullPath)
|
||||
|
||||
// Sync title to bridge session on claude.ai/code (best-effort, non-blocking).
|
||||
// v2 env-less bridge stores cse_* in replBridgeSessionId —
|
||||
// updateBridgeSessionTitle retags internally for the compat endpoint.
|
||||
const appState = context.getAppState()
|
||||
const bridgeSessionId = appState.replBridgeSessionId
|
||||
if (bridgeSessionId) {
|
||||
const tokenOverride = getBridgeTokenOverride()
|
||||
void import('../../bridge/createSession.js').then(
|
||||
({ updateBridgeSessionTitle }) =>
|
||||
updateBridgeSessionTitle(bridgeSessionId, newName, {
|
||||
baseUrl: getBridgeBaseUrlOverride(),
|
||||
getAccessToken: tokenOverride ? () => tokenOverride : undefined,
|
||||
}).catch(() => {}),
|
||||
)
|
||||
}
|
||||
|
||||
// Also persist as the session's agent name for prompt-bar display
|
||||
await saveAgentName(sessionId, newName, fullPath)
|
||||
context.setAppState(prev => ({
|
||||
...prev,
|
||||
standaloneAgentContext: {
|
||||
...prev.standaloneAgentContext,
|
||||
name: newName,
|
||||
},
|
||||
}))
|
||||
|
||||
onDone(`Session renamed to: ${newName}`, { display: 'system' })
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { SkillsMenu } from '../../components/skills/SkillsMenu.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
return <SkillsMenu onExit={onDone} commands={context.options.commands} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJTa2lsbHNNZW51IiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwib3B0aW9ucyIsImNvbW1hbmRzIl0sInNvdXJjZXMiOlsic2tpbGxzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ29udGV4dCB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgU2tpbGxzTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvc2tpbGxzL1NraWxsc01lbnUuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogTG9jYWxKU1hDb21tYW5kQ29udGV4dCxcbik6IFByb21pc2U8UmVhY3QuUmVhY3ROb2RlPiB7XG4gIHJldHVybiA8U2tpbGxzTWVudSBvbkV4aXQ9e29uRG9uZX0gY29tbWFuZHM9e2NvbnRleHQub3B0aW9ucy5jb21tYW5kc30gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixjQUFjQyxzQkFBc0IsUUFBUSxtQkFBbUI7QUFDL0QsU0FBU0MsVUFBVSxRQUFRLHVDQUF1QztBQUNsRSxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsc0JBQXNCLENBQ2hDLEVBQUVNLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDUSxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsVUFBVSxDQUFDLE1BQU0sQ0FBQyxDQUFDSCxNQUFNLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQ0MsT0FBTyxDQUFDRyxPQUFPLENBQUNDLFFBQVEsQ0FBQyxHQUFHO0FBQzNFIiwiaWdub3JlTGlzdCI6W119
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
|
||||
import type { Command } from '../commands.js';
|
||||
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js';
|
||||
const statusline = {
|
||||
type: 'prompt',
|
||||
description: "Set up Claude Code's status line UI",
|
||||
contentLength: 0,
|
||||
// Dynamic content
|
||||
aliases: [],
|
||||
name: 'statusline',
|
||||
progressMessage: 'setting up statusLine',
|
||||
allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'],
|
||||
source: 'builtin',
|
||||
disableNonInteractive: true,
|
||||
async getPromptForCommand(args): Promise<ContentBlockParam[]> {
|
||||
const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration';
|
||||
return [{
|
||||
type: 'text',
|
||||
text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"`
|
||||
}];
|
||||
}
|
||||
} satisfies Command;
|
||||
export default statusline;
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJDb250ZW50QmxvY2tQYXJhbSIsIkNvbW1hbmQiLCJBR0VOVF9UT09MX05BTUUiLCJzdGF0dXNsaW5lIiwidHlwZSIsImRlc2NyaXB0aW9uIiwiY29udGVudExlbmd0aCIsImFsaWFzZXMiLCJuYW1lIiwicHJvZ3Jlc3NNZXNzYWdlIiwiYWxsb3dlZFRvb2xzIiwic291cmNlIiwiZGlzYWJsZU5vbkludGVyYWN0aXZlIiwiZ2V0UHJvbXB0Rm9yQ29tbWFuZCIsImFyZ3MiLCJQcm9taXNlIiwicHJvbXB0IiwidHJpbSIsInRleHQiXSwic291cmNlcyI6WyJzdGF0dXNsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IENvbnRlbnRCbG9ja1BhcmFtIH0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzL2luZGV4Lm1qcydcbmltcG9ydCB0eXBlIHsgQ29tbWFuZCB9IGZyb20gJy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgQUdFTlRfVE9PTF9OQU1FIH0gZnJvbSAnLi4vdG9vbHMvQWdlbnRUb29sL2NvbnN0YW50cy5qcydcblxuY29uc3Qgc3RhdHVzbGluZSA9IHtcbiAgdHlwZTogJ3Byb21wdCcsXG4gIGRlc2NyaXB0aW9uOiBcIlNldCB1cCBDbGF1ZGUgQ29kZSdzIHN0YXR1cyBsaW5lIFVJXCIsXG4gIGNvbnRlbnRMZW5ndGg6IDAsIC8vIER5bmFtaWMgY29udGVudFxuICBhbGlhc2VzOiBbXSxcbiAgbmFtZTogJ3N0YXR1c2xpbmUnLFxuICBwcm9ncmVzc01lc3NhZ2U6ICdzZXR0aW5nIHVwIHN0YXR1c0xpbmUnLFxuICBhbGxvd2VkVG9vbHM6IFtcbiAgICBBR0VOVF9UT09MX05BTUUsXG4gICAgJ1JlYWQofi8qKiknLFxuICAgICdFZGl0KH4vLmNsYXVkZS9zZXR0aW5ncy5qc29uKScsXG4gIF0sXG4gIHNvdXJjZTogJ2J1aWx0aW4nLFxuICBkaXNhYmxlTm9uSW50ZXJhY3RpdmU6IHRydWUsXG4gIGFzeW5jIGdldFByb21wdEZvckNvbW1hbmQoYXJncyk6IFByb21pc2U8Q29udGVudEJsb2NrUGFyYW1bXT4ge1xuICAgIGNvbnN0IHByb21wdCA9XG4gICAgICBhcmdzLnRyaW0oKSB8fCAnQ29uZmlndXJlIG15IHN0YXR1c0xpbmUgZnJvbSBteSBzaGVsbCBQUzEgY29uZmlndXJhdGlvbidcbiAgICByZXR1cm4gW1xuICAgICAge1xuICAgICAgICB0eXBlOiAndGV4dCcsXG4gICAgICAgIHRleHQ6IGBDcmVhdGUgYW4gJHtBR0VOVF9UT09MX05BTUV9IHdpdGggc3ViYWdlbnRfdHlwZSBcInN0YXR1c2xpbmUtc2V0dXBcIiBhbmQgdGhlIHByb21wdCBcIiR7cHJvbXB0fVwiYCxcbiAgICAgIH0sXG4gICAgXVxuICB9LFxufSBzYXRpc2ZpZXMgQ29tbWFuZFxuXG5leHBvcnQgZGVmYXVsdCBzdGF0dXNsaW5lXG4iXSwibWFwcGluZ3MiOiJBQUFBLGNBQWNBLGlCQUFpQixRQUFRLHVDQUF1QztBQUM5RSxjQUFjQyxPQUFPLFFBQVEsZ0JBQWdCO0FBQzdDLFNBQVNDLGVBQWUsUUFBUSxpQ0FBaUM7QUFFakUsTUFBTUMsVUFBVSxHQUFHO0VBQ2pCQyxJQUFJLEVBQUUsUUFBUTtFQUNkQyxXQUFXLEVBQUUscUNBQXFDO0VBQ2xEQyxhQUFhLEVBQUUsQ0FBQztFQUFFO0VBQ2xCQyxPQUFPLEVBQUUsRUFBRTtFQUNYQyxJQUFJLEVBQUUsWUFBWTtFQUNsQkMsZUFBZSxFQUFFLHVCQUF1QjtFQUN4Q0MsWUFBWSxFQUFFLENBQ1pSLGVBQWUsRUFDZixZQUFZLEVBQ1osK0JBQStCLENBQ2hDO0VBQ0RTLE1BQU0sRUFBRSxTQUFTO0VBQ2pCQyxxQkFBcUIsRUFBRSxJQUFJO0VBQzNCLE1BQU1DLG1CQUFtQkEsQ0FBQ0MsSUFBSSxDQUFDLEVBQUVDLE9BQU8sQ0FBQ2YsaUJBQWlCLEVBQUUsQ0FBQyxDQUFDO0lBQzVELE1BQU1nQixNQUFNLEdBQ1ZGLElBQUksQ0FBQ0csSUFBSSxDQUFDLENBQUMsSUFBSSx5REFBeUQ7SUFDMUUsT0FBTyxDQUNMO01BQ0ViLElBQUksRUFBRSxNQUFNO01BQ1pjLElBQUksRUFBRSxhQUFhaEIsZUFBZSwwREFBMERjLE1BQU07SUFDcEcsQ0FBQyxDQUNGO0VBQ0g7QUFDRixDQUFDLFdBQVdmLE9BQU87QUFFbkIsZUFBZUUsVUFBVSIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
@@ -0,0 +1,34 @@
|
||||
// @generated stub from scan-missing-imports
|
||||
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
|
||||
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
|
||||
// bun build resolver 的占位符。
|
||||
const __target = function noop() {}
|
||||
const __handler: ProxyHandler<any> = {
|
||||
get(_t, prop) {
|
||||
if (prop === '__esModule') return true
|
||||
if (prop === 'default') return new Proxy(__target, __handler)
|
||||
if (prop === Symbol.toPrimitive) return () => undefined
|
||||
if (prop === Symbol.iterator) return function* () {}
|
||||
if (prop === Symbol.asyncIterator) return async function* () {}
|
||||
if (prop === 'then') return undefined
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
apply() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
construct() {
|
||||
return new Proxy(__target, __handler)
|
||||
},
|
||||
}
|
||||
const stub: any = new Proxy(__target, __handler)
|
||||
export default stub
|
||||
export const __stubMissing = true
|
||||
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
|
||||
export const createCachedMCState = stub
|
||||
export const isCachedMicrocompactEnabled = stub
|
||||
export const isModelSupportedForCacheEditing = stub
|
||||
export const getCachedMCConfig = stub
|
||||
export const markToolsSentToAPI = stub
|
||||
export const resetCachedMCState = stub
|
||||
export const checkProtectedNamespace = stub
|
||||
export const getCoordinatorUserContext = stub
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as React from 'react';
|
||||
import type { LocalJSXCommandContext } from '../../commands.js';
|
||||
import { BackgroundTasksDialog } from '../../components/tasks/BackgroundTasksDialog.js';
|
||||
import type { LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
|
||||
return <BackgroundTasksDialog toolUseContext={context} onDone={onDone} />;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJCYWNrZ3JvdW5kVGFza3NEaWFsb2ciLCJMb2NhbEpTWENvbW1hbmRPbkRvbmUiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIlByb21pc2UiLCJSZWFjdE5vZGUiXSwic291cmNlcyI6WyJ0YXNrcy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZENvbnRleHQgfSBmcm9tICcuLi8uLi9jb21tYW5kcy5qcydcbmltcG9ydCB7IEJhY2tncm91bmRUYXNrc0RpYWxvZyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvdGFza3MvQmFja2dyb3VuZFRhc2tzRGlhbG9nLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4gIGNvbnRleHQ6IExvY2FsSlNYQ29tbWFuZENvbnRleHQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPEJhY2tncm91bmRUYXNrc0RpYWxvZyB0b29sVXNlQ29udGV4dD17Y29udGV4dH0gb25Eb25lPXtvbkRvbmV9IC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsY0FBY0Msc0JBQXNCLFFBQVEsbUJBQW1CO0FBQy9ELFNBQVNDLHFCQUFxQixRQUFRLGlEQUFpRDtBQUN2RixjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsc0JBQXNCLENBQ2hDLEVBQUVNLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDUSxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMscUJBQXFCLENBQUMsY0FBYyxDQUFDLENBQUNGLE9BQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDRCxNQUFNLENBQUMsR0FBRztBQUMzRSIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Command } from '../../commands.js'
|
||||
import {
|
||||
isVoiceGrowthBookEnabled,
|
||||
isVoiceModeEnabled,
|
||||
} from '../../voice/voiceModeEnabled.js'
|
||||
|
||||
const voice = {
|
||||
type: 'local',
|
||||
name: 'voice',
|
||||
description: 'Toggle voice mode',
|
||||
availability: ['claude-ai'],
|
||||
isEnabled: () => isVoiceGrowthBookEnabled(),
|
||||
get isHidden() {
|
||||
return !isVoiceModeEnabled()
|
||||
},
|
||||
supportsNonInteractive: false,
|
||||
load: () => import('./voice.js'),
|
||||
} satisfies Command
|
||||
|
||||
export default voice
|
||||
@@ -0,0 +1,653 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { isDeepStrictEqual } from 'util'
|
||||
import OptionMap from './option-map.js'
|
||||
import type { OptionWithDescription } from './select.js'
|
||||
|
||||
type State<T> = {
|
||||
/**
|
||||
* Map where key is option's value and value is option's index.
|
||||
*/
|
||||
optionMap: OptionMap<T>
|
||||
|
||||
/**
|
||||
* Number of visible options.
|
||||
*/
|
||||
visibleOptionCount: number
|
||||
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
}
|
||||
|
||||
type Action<T> =
|
||||
| FocusNextOptionAction
|
||||
| FocusPreviousOptionAction
|
||||
| FocusNextPageAction
|
||||
| FocusPreviousPageAction
|
||||
| SetFocusAction<T>
|
||||
| ResetAction<T>
|
||||
|
||||
type SetFocusAction<T> = {
|
||||
type: 'set-focus'
|
||||
value: T
|
||||
}
|
||||
|
||||
type FocusNextOptionAction = {
|
||||
type: 'focus-next-option'
|
||||
}
|
||||
|
||||
type FocusPreviousOptionAction = {
|
||||
type: 'focus-previous-option'
|
||||
}
|
||||
|
||||
type FocusNextPageAction = {
|
||||
type: 'focus-next-page'
|
||||
}
|
||||
|
||||
type FocusPreviousPageAction = {
|
||||
type: 'focus-previous-page'
|
||||
}
|
||||
|
||||
type ResetAction<T> = {
|
||||
type: 'reset'
|
||||
state: State<T>
|
||||
}
|
||||
|
||||
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
|
||||
switch (action.type) {
|
||||
case 'focus-next-option': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to first item if at the end
|
||||
const next = item.next || state.optionMap.first
|
||||
|
||||
if (!next) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to first, reset viewport to start
|
||||
if (!item.next && next === state.optionMap.first) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: 0,
|
||||
visibleToIndex: state.visibleOptionCount,
|
||||
}
|
||||
}
|
||||
|
||||
const needsToScroll = next.index >= state.visibleToIndex
|
||||
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
state.visibleToIndex + 1,
|
||||
)
|
||||
|
||||
const nextVisibleFromIndex = nextVisibleToIndex - state.visibleOptionCount
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: next.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-previous-option': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Wrap to last item if at the beginning
|
||||
const previous = item.previous || state.optionMap.last
|
||||
|
||||
if (!previous) {
|
||||
return state
|
||||
}
|
||||
|
||||
// When wrapping to last, reset viewport to end
|
||||
if (!item.previous && previous === state.optionMap.last) {
|
||||
const nextVisibleToIndex = state.optionMap.size
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
const needsToScroll = previous.index <= state.visibleFromIndex
|
||||
|
||||
if (!needsToScroll) {
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
}
|
||||
}
|
||||
|
||||
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1)
|
||||
|
||||
const nextVisibleToIndex = nextVisibleFromIndex + state.visibleOptionCount
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: previous.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-next-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.min(
|
||||
state.optionMap.size - 1,
|
||||
item.index + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
targetItem.index + 1,
|
||||
)
|
||||
const nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'focus-previous-page': {
|
||||
if (state.focusedValue === undefined) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(state.focusedValue)
|
||||
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Move by a full page (visibleOptionCount items)
|
||||
const targetIndex = Math.max(0, item.index - state.visibleOptionCount)
|
||||
|
||||
// Find the item at the target index
|
||||
let targetItem = state.optionMap.first
|
||||
while (targetItem && targetItem.index < targetIndex) {
|
||||
if (targetItem.next) {
|
||||
targetItem = targetItem.next
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Update the visible range to include the new focused item
|
||||
const nextVisibleFromIndex = Math.max(0, targetItem.index)
|
||||
const nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: targetItem.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
case 'reset': {
|
||||
return action.state
|
||||
}
|
||||
|
||||
case 'set-focus': {
|
||||
// Early return if already focused on this value
|
||||
if (state.focusedValue === action.value) {
|
||||
return state
|
||||
}
|
||||
|
||||
const item = state.optionMap.get(action.value)
|
||||
if (!item) {
|
||||
return state
|
||||
}
|
||||
|
||||
// Check if the item is already in view
|
||||
if (
|
||||
item.index >= state.visibleFromIndex &&
|
||||
item.index < state.visibleToIndex
|
||||
) {
|
||||
// Already visible, just update focus
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
}
|
||||
}
|
||||
|
||||
// Need to scroll to make the item visible
|
||||
// Scroll as little as possible - put item at edge of viewport
|
||||
let nextVisibleFromIndex: number
|
||||
let nextVisibleToIndex: number
|
||||
|
||||
if (item.index < state.visibleFromIndex) {
|
||||
// Item is above viewport - scroll up to put it at the top
|
||||
nextVisibleFromIndex = item.index
|
||||
nextVisibleToIndex = Math.min(
|
||||
state.optionMap.size,
|
||||
nextVisibleFromIndex + state.visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below viewport - scroll down to put it at the bottom
|
||||
nextVisibleToIndex = Math.min(state.optionMap.size, item.index + 1)
|
||||
nextVisibleFromIndex = Math.max(
|
||||
0,
|
||||
nextVisibleToIndex - state.visibleOptionCount,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
focusedValue: action.value,
|
||||
visibleFromIndex: nextVisibleFromIndex,
|
||||
visibleToIndex: nextVisibleToIndex,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type UseSelectNavigationProps<T> = {
|
||||
/**
|
||||
* Number of items to display.
|
||||
*
|
||||
* @default 5
|
||||
*/
|
||||
visibleOptionCount?: number
|
||||
|
||||
/**
|
||||
* Options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Initially focused option's value.
|
||||
*/
|
||||
initialFocusValue?: T
|
||||
|
||||
/**
|
||||
* Callback for focusing an option.
|
||||
*/
|
||||
onFocus?: (value: T) => void
|
||||
|
||||
/**
|
||||
* Value to focus
|
||||
*/
|
||||
focusValue?: T
|
||||
}
|
||||
|
||||
export type SelectNavigation<T> = {
|
||||
/**
|
||||
* Value of the currently focused option.
|
||||
*/
|
||||
focusedValue: T | undefined
|
||||
|
||||
/**
|
||||
* 1-based index of the focused option in the full list.
|
||||
* Returns 0 if no option is focused.
|
||||
*/
|
||||
focusedIndex: number
|
||||
|
||||
/**
|
||||
* Index of the first visible option.
|
||||
*/
|
||||
visibleFromIndex: number
|
||||
|
||||
/**
|
||||
* Index of the last visible option.
|
||||
*/
|
||||
visibleToIndex: number
|
||||
|
||||
/**
|
||||
* All options.
|
||||
*/
|
||||
options: OptionWithDescription<T>[]
|
||||
|
||||
/**
|
||||
* Visible options.
|
||||
*/
|
||||
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
|
||||
|
||||
/**
|
||||
* Whether the focused option is an input type.
|
||||
*/
|
||||
isInInput: boolean
|
||||
|
||||
/**
|
||||
* Focus next option and scroll the list down, if needed.
|
||||
*/
|
||||
focusNextOption: () => void
|
||||
|
||||
/**
|
||||
* Focus previous option and scroll the list up, if needed.
|
||||
*/
|
||||
focusPreviousOption: () => void
|
||||
|
||||
/**
|
||||
* Focus next page and scroll the list down by a page.
|
||||
*/
|
||||
focusNextPage: () => void
|
||||
|
||||
/**
|
||||
* Focus previous page and scroll the list up by a page.
|
||||
*/
|
||||
focusPreviousPage: () => void
|
||||
|
||||
/**
|
||||
* Focus a specific option by value.
|
||||
*/
|
||||
focusOption: (value: T | undefined) => void
|
||||
}
|
||||
|
||||
const createDefaultState = <T>({
|
||||
visibleOptionCount: customVisibleOptionCount,
|
||||
options,
|
||||
initialFocusValue,
|
||||
currentViewport,
|
||||
}: Pick<UseSelectNavigationProps<T>, 'visibleOptionCount' | 'options'> & {
|
||||
initialFocusValue?: T
|
||||
currentViewport?: { visibleFromIndex: number; visibleToIndex: number }
|
||||
}): State<T> => {
|
||||
const visibleOptionCount =
|
||||
typeof customVisibleOptionCount === 'number'
|
||||
? Math.min(customVisibleOptionCount, options.length)
|
||||
: options.length
|
||||
|
||||
const optionMap = new OptionMap<T>(options)
|
||||
const focusedItem =
|
||||
initialFocusValue !== undefined && optionMap.get(initialFocusValue)
|
||||
const focusedValue = focusedItem ? initialFocusValue : optionMap.first?.value
|
||||
|
||||
let visibleFromIndex = 0
|
||||
let visibleToIndex = visibleOptionCount
|
||||
|
||||
// When there's a valid focused item, adjust viewport to show it
|
||||
if (focusedItem) {
|
||||
const focusedIndex = focusedItem.index
|
||||
|
||||
if (currentViewport) {
|
||||
// If focused item is already in the current viewport range, try to preserve it
|
||||
if (
|
||||
focusedIndex >= currentViewport.visibleFromIndex &&
|
||||
focusedIndex < currentViewport.visibleToIndex
|
||||
) {
|
||||
// Keep the same viewport if it's valid
|
||||
visibleFromIndex = currentViewport.visibleFromIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
currentViewport.visibleToIndex,
|
||||
)
|
||||
} else {
|
||||
// Need to adjust viewport to show focused item
|
||||
// Use minimal scrolling - put item at edge of viewport
|
||||
if (focusedIndex < currentViewport.visibleFromIndex) {
|
||||
// Item is above current viewport - scroll up to put it at the top
|
||||
visibleFromIndex = focusedIndex
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
visibleFromIndex + visibleOptionCount,
|
||||
)
|
||||
} else {
|
||||
// Item is below current viewport - scroll down to put it at the bottom
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
}
|
||||
} else if (focusedIndex >= visibleOptionCount) {
|
||||
// No current viewport but focused item is outside default viewport
|
||||
// Scroll to show the focused item at the bottom of the viewport
|
||||
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
|
||||
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
|
||||
}
|
||||
|
||||
// Ensure viewport bounds are valid
|
||||
visibleFromIndex = Math.max(
|
||||
0,
|
||||
Math.min(visibleFromIndex, optionMap.size - 1),
|
||||
)
|
||||
visibleToIndex = Math.min(
|
||||
optionMap.size,
|
||||
Math.max(visibleOptionCount, visibleToIndex),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
optionMap,
|
||||
visibleOptionCount,
|
||||
focusedValue,
|
||||
visibleFromIndex,
|
||||
visibleToIndex,
|
||||
}
|
||||
}
|
||||
|
||||
export function useSelectNavigation<T>({
|
||||
visibleOptionCount = 5,
|
||||
options,
|
||||
initialFocusValue,
|
||||
onFocus,
|
||||
focusValue,
|
||||
}: UseSelectNavigationProps<T>): SelectNavigation<T> {
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer<T>,
|
||||
{
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue: focusValue || initialFocusValue,
|
||||
} as Parameters<typeof createDefaultState<T>>[0],
|
||||
createDefaultState<T>,
|
||||
)
|
||||
|
||||
// Store onFocus in a ref to avoid re-running useEffect when callback changes
|
||||
const onFocusRef = useRef(onFocus)
|
||||
onFocusRef.current = onFocus
|
||||
|
||||
const [lastOptions, setLastOptions] = useState(options)
|
||||
|
||||
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
|
||||
dispatch({
|
||||
type: 'reset',
|
||||
state: createDefaultState({
|
||||
visibleOptionCount,
|
||||
options,
|
||||
initialFocusValue:
|
||||
focusValue ?? state.focusedValue ?? initialFocusValue,
|
||||
currentViewport: {
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
setLastOptions(options)
|
||||
}
|
||||
|
||||
const focusNextOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousOption = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-option',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusNextPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-next-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusPreviousPage = useCallback(() => {
|
||||
dispatch({
|
||||
type: 'focus-previous-page',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const focusOption = useCallback((value: T | undefined) => {
|
||||
if (value !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleOptions = useMemo(() => {
|
||||
return options
|
||||
.map((option, index) => ({
|
||||
...option,
|
||||
index,
|
||||
}))
|
||||
.slice(state.visibleFromIndex, state.visibleToIndex)
|
||||
}, [options, state.visibleFromIndex, state.visibleToIndex])
|
||||
|
||||
// Validate that focusedValue exists in current options.
|
||||
// This handles the case where options change during render but the reset
|
||||
// action hasn't been processed yet - without this, the cursor would disappear
|
||||
// because focusedValue points to an option that no longer exists.
|
||||
const validatedFocusedValue = useMemo(() => {
|
||||
if (state.focusedValue === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const exists = options.some(opt => opt.value === state.focusedValue)
|
||||
if (exists) {
|
||||
return state.focusedValue
|
||||
}
|
||||
// Fall back to first option if focused value doesn't exist
|
||||
return options[0]?.value
|
||||
}, [state.focusedValue, options])
|
||||
|
||||
const isInInput = useMemo(() => {
|
||||
const focusedOption = options.find(
|
||||
opt => opt.value === validatedFocusedValue,
|
||||
)
|
||||
return focusedOption?.type === 'input'
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
// Call onFocus with the validated value (what's actually displayed),
|
||||
// not the internal state value which may be stale if options changed.
|
||||
// Use ref to avoid re-running when callback reference changes.
|
||||
useEffect(() => {
|
||||
if (validatedFocusedValue !== undefined) {
|
||||
onFocusRef.current?.(validatedFocusedValue)
|
||||
}
|
||||
}, [validatedFocusedValue])
|
||||
|
||||
// Allow parent to programmatically set focus via focusValue prop
|
||||
useEffect(() => {
|
||||
if (focusValue !== undefined) {
|
||||
dispatch({
|
||||
type: 'set-focus',
|
||||
value: focusValue,
|
||||
})
|
||||
}
|
||||
}, [focusValue])
|
||||
|
||||
// Compute 1-based focused index for scroll position display
|
||||
const focusedIndex = useMemo(() => {
|
||||
if (validatedFocusedValue === undefined) {
|
||||
return 0
|
||||
}
|
||||
const index = options.findIndex(opt => opt.value === validatedFocusedValue)
|
||||
return index >= 0 ? index + 1 : 0
|
||||
}, [validatedFocusedValue, options])
|
||||
|
||||
return {
|
||||
focusedValue: validatedFocusedValue,
|
||||
focusedIndex,
|
||||
visibleFromIndex: state.visibleFromIndex,
|
||||
visibleToIndex: state.visibleToIndex,
|
||||
visibleOptions,
|
||||
isInInput: isInInput ?? false,
|
||||
focusNextOption,
|
||||
focusPreviousOption,
|
||||
focusNextPage,
|
||||
focusPreviousPage,
|
||||
focusOption,
|
||||
options,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const TEAMMATE_SELECT_HINT = 'shift + ↑/↓ to select'
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react'
|
||||
import { stringWidth } from '../../ink/stringWidth.js'
|
||||
import { type DOMElement, useAnimationFrame } from '../../ink.js'
|
||||
import type { SpinnerMode } from './types.js'
|
||||
|
||||
export function useShimmerAnimation(
|
||||
mode: SpinnerMode,
|
||||
message: string,
|
||||
isStalled: boolean,
|
||||
): [ref: (element: DOMElement | null) => void, glimmerIndex: number] {
|
||||
const glimmerSpeed = mode === 'requesting' ? 50 : 200
|
||||
// Pass null when stalled to unsubscribe from the clock — otherwise the
|
||||
// setInterval keeps firing at 20fps even when the shimmer isn't visible.
|
||||
// Notably, if the caller never attaches `ref` (e.g. conditional JSX),
|
||||
// useTerminalViewport stays at its initial isVisible:true and the
|
||||
// viewport-pause never kicks in, so this is the only stop mechanism.
|
||||
const [ref, time] = useAnimationFrame(isStalled ? null : glimmerSpeed)
|
||||
const messageWidth = useMemo(() => stringWidth(message), [message])
|
||||
|
||||
if (isStalled) {
|
||||
return [ref, -100]
|
||||
}
|
||||
|
||||
const cyclePosition = Math.floor(time / glimmerSpeed)
|
||||
const cycleLength = messageWidth + 20
|
||||
|
||||
if (mode === 'requesting') {
|
||||
return [ref, (cyclePosition % cycleLength) - 10]
|
||||
}
|
||||
return [ref, messageWidth + 10 - (cyclePosition % cycleLength)]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SettingSource } from 'src/utils/settings/constants.js'
|
||||
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
|
||||
|
||||
export const AGENT_PATHS = {
|
||||
FOLDER_NAME: '.claude',
|
||||
AGENTS_DIR: 'agents',
|
||||
} as const
|
||||
|
||||
// Base types for common patterns
|
||||
type WithPreviousMode = { previousMode: ModeState }
|
||||
type WithAgent = { agent: AgentDefinition }
|
||||
|
||||
// Simplified state type using intersection types
|
||||
export type ModeState =
|
||||
| { mode: 'main-menu' }
|
||||
| { mode: 'list-agents'; source: SettingSource | 'all' | 'built-in' }
|
||||
| ({ mode: 'agent-menu' } & WithAgent & WithPreviousMode)
|
||||
| ({ mode: 'view-agent' } & WithAgent & WithPreviousMode)
|
||||
| { mode: 'create-agent' }
|
||||
| ({ mode: 'edit-agent' } & WithAgent & WithPreviousMode)
|
||||
| ({ mode: 'delete-confirm' } & WithAgent & WithPreviousMode)
|
||||
|
||||
export type AgentValidationResult = {
|
||||
isValid: boolean
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { MCPAgentServerMenu } from './MCPAgentServerMenu.js'
|
||||
export { MCPListPanel } from './MCPListPanel.js'
|
||||
export { MCPReconnect } from './MCPReconnect.js'
|
||||
export { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js'
|
||||
export { MCPSettings } from './MCPSettings.js'
|
||||
export { MCPStdioServerMenu } from './MCPStdioServerMenu.js'
|
||||
export { MCPToolDetailView } from './MCPToolDetailView.js'
|
||||
export { MCPToolListView } from './MCPToolListView.js'
|
||||
export type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js'
|
||||
@@ -0,0 +1,31 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React from 'react';
|
||||
import { Box, Text } from '../../ink.js';
|
||||
type Props = {
|
||||
addMargin: boolean;
|
||||
};
|
||||
export function AssistantRedactedThinkingMessage(t0) {
|
||||
const $ = _c(3);
|
||||
const {
|
||||
addMargin: t1
|
||||
} = t0;
|
||||
const addMargin = t1 === undefined ? false : t1;
|
||||
const t2 = addMargin ? 1 : 0;
|
||||
let t3;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t3 = <Text dimColor={true} italic={true}>✻ Thinking…</Text>;
|
||||
$[0] = t3;
|
||||
} else {
|
||||
t3 = $[0];
|
||||
}
|
||||
let t4;
|
||||
if ($[1] !== t2) {
|
||||
t4 = <Box marginTop={t2}>{t3}</Box>;
|
||||
$[1] = t2;
|
||||
$[2] = t4;
|
||||
} else {
|
||||
t4 = $[2];
|
||||
}
|
||||
return t4;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlRleHQiLCJQcm9wcyIsImFkZE1hcmdpbiIsIkFzc2lzdGFudFJlZGFjdGVkVGhpbmtpbmdNZXNzYWdlIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwidDMiLCJTeW1ib2wiLCJmb3IiLCJ0NCJdLCJzb3VyY2VzIjpbIkFzc2lzdGFudFJlZGFjdGVkVGhpbmtpbmdNZXNzYWdlLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIGFkZE1hcmdpbjogYm9vbGVhblxufVxuXG5leHBvcnQgZnVuY3Rpb24gQXNzaXN0YW50UmVkYWN0ZWRUaGlua2luZ01lc3NhZ2Uoe1xuICBhZGRNYXJnaW4gPSBmYWxzZSxcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgcmV0dXJuIChcbiAgICA8Qm94IG1hcmdpblRvcD17YWRkTWFyZ2luID8gMSA6IDB9PlxuICAgICAgPFRleHQgZGltQ29sb3IgaXRhbGljPlxuICAgICAgICDinLsgVGhpbmtpbmfigKZcbiAgICAgIDwvVGV4dD5cbiAgICA8L0JveD5cbiAgKVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsU0FBU0MsR0FBRyxFQUFFQyxJQUFJLFFBQVEsY0FBYztBQUV4QyxLQUFLQyxLQUFLLEdBQUc7RUFDWEMsU0FBUyxFQUFFLE9BQU87QUFDcEIsQ0FBQztBQUVELE9BQU8sU0FBQUMsaUNBQUFDLEVBQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBMEM7SUFBQUosU0FBQSxFQUFBSztFQUFBLElBQUFILEVBRXpDO0VBRE4sTUFBQUYsU0FBQSxHQUFBSyxFQUFpQixLQUFqQkMsU0FBaUIsR0FBakIsS0FBaUIsR0FBakJELEVBQWlCO0VBR0MsTUFBQUUsRUFBQSxHQUFBUCxTQUFTLEdBQVQsQ0FBaUIsR0FBakIsQ0FBaUI7RUFBQSxJQUFBUSxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBTSxNQUFBLENBQUFDLEdBQUE7SUFDL0JGLEVBQUEsSUFBQyxJQUFJLENBQUMsUUFBUSxDQUFSLEtBQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBTixLQUFLLENBQUMsQ0FBQyxXQUV0QixFQUZDLElBQUksQ0FFRTtJQUFBTCxDQUFBLE1BQUFLLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFMLENBQUE7RUFBQTtFQUFBLElBQUFRLEVBQUE7RUFBQSxJQUFBUixDQUFBLFFBQUFJLEVBQUE7SUFIVEksRUFBQSxJQUFDLEdBQUcsQ0FBWSxTQUFpQixDQUFqQixDQUFBSixFQUFnQixDQUFDLENBQy9CLENBQUFDLEVBRU0sQ0FDUixFQUpDLEdBQUcsQ0FJRTtJQUFBTCxDQUFBLE1BQUFJLEVBQUE7SUFBQUosQ0FBQSxNQUFBUSxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUixDQUFBO0VBQUE7RUFBQSxPQUpOUSxFQUlNO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useAppState } from 'src/state/AppState.js'
|
||||
import { useKeybindings } from '../../../keybindings/useKeybinding.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../../services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
|
||||
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import type { CompletionType } from '../../../utils/unaryLogging.js'
|
||||
import type { ToolUseConfirm } from '../PermissionRequest.js'
|
||||
import {
|
||||
type FileOperationType,
|
||||
getFilePermissionOptions,
|
||||
type PermissionOption,
|
||||
type PermissionOptionWithLabel,
|
||||
} from './permissionOptions.js'
|
||||
import {
|
||||
PERMISSION_HANDLERS,
|
||||
type PermissionHandlerParams,
|
||||
} from './usePermissionHandler.js'
|
||||
|
||||
export interface ToolInput {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type UseFilePermissionDialogProps<T extends ToolInput> = {
|
||||
filePath: string
|
||||
completionType: CompletionType
|
||||
languageName: string | Promise<string>
|
||||
toolUseConfirm: ToolUseConfirm
|
||||
onDone: () => void
|
||||
onReject: () => void
|
||||
parseInput: (input: unknown) => T
|
||||
operationType?: FileOperationType
|
||||
}
|
||||
|
||||
export type UseFilePermissionDialogResult<T> = {
|
||||
options: PermissionOptionWithLabel[]
|
||||
onChange: (option: PermissionOption, input: T, feedback?: string) => void
|
||||
acceptFeedback: string
|
||||
rejectFeedback: string
|
||||
focusedOption: string
|
||||
setFocusedOption: (option: string) => void
|
||||
handleInputModeToggle: (value: string) => void
|
||||
yesInputMode: boolean
|
||||
noInputMode: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for handling file permission dialogs with common logic
|
||||
*/
|
||||
export function useFilePermissionDialog<T extends ToolInput>({
|
||||
filePath,
|
||||
completionType,
|
||||
languageName,
|
||||
toolUseConfirm,
|
||||
onDone,
|
||||
onReject,
|
||||
parseInput,
|
||||
operationType = 'write',
|
||||
}: UseFilePermissionDialogProps<T>): UseFilePermissionDialogResult<T> {
|
||||
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
|
||||
const [acceptFeedback, setAcceptFeedback] = useState('')
|
||||
const [rejectFeedback, setRejectFeedback] = useState('')
|
||||
const [focusedOption, setFocusedOption] = useState('yes')
|
||||
const [yesInputMode, setYesInputMode] = useState(false)
|
||||
const [noInputMode, setNoInputMode] = useState(false)
|
||||
// Track whether user ever entered feedback mode (persists after collapse)
|
||||
const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false)
|
||||
const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false)
|
||||
|
||||
// Generate options based on context
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getFilePermissionOptions({
|
||||
filePath,
|
||||
toolPermissionContext,
|
||||
operationType,
|
||||
onRejectFeedbackChange: setRejectFeedback,
|
||||
onAcceptFeedbackChange: setAcceptFeedback,
|
||||
yesInputMode,
|
||||
noInputMode,
|
||||
}),
|
||||
[filePath, toolPermissionContext, operationType, yesInputMode, noInputMode],
|
||||
)
|
||||
|
||||
// Handle option selection using shared handlers
|
||||
const onChange = useCallback(
|
||||
(option: PermissionOption, input: T, feedback?: string) => {
|
||||
const params: PermissionHandlerParams = {
|
||||
messageId: toolUseConfirm.assistantMessage.message.id,
|
||||
path: filePath,
|
||||
toolUseConfirm,
|
||||
toolPermissionContext,
|
||||
onDone,
|
||||
onReject,
|
||||
completionType,
|
||||
languageName,
|
||||
operationType,
|
||||
}
|
||||
|
||||
// Override the input in toolUseConfirm to pass the parsed input
|
||||
const originalOnAllow = toolUseConfirm.onAllow
|
||||
toolUseConfirm.onAllow = (
|
||||
_input: unknown,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
) => {
|
||||
originalOnAllow(input, permissionUpdates, feedback)
|
||||
}
|
||||
|
||||
const handler = PERMISSION_HANDLERS[option.type]
|
||||
handler(params, {
|
||||
feedback,
|
||||
hasFeedback: !!feedback,
|
||||
enteredFeedbackMode:
|
||||
option.type === 'accept-once'
|
||||
? yesFeedbackModeEntered
|
||||
: noFeedbackModeEntered,
|
||||
scope: option.type === 'accept-session' ? option.scope : undefined,
|
||||
})
|
||||
},
|
||||
[
|
||||
filePath,
|
||||
completionType,
|
||||
languageName,
|
||||
toolUseConfirm,
|
||||
toolPermissionContext,
|
||||
onDone,
|
||||
onReject,
|
||||
operationType,
|
||||
yesFeedbackModeEntered,
|
||||
noFeedbackModeEntered,
|
||||
],
|
||||
)
|
||||
|
||||
// Handler for confirm:cycleMode - select accept-session option
|
||||
const handleCycleMode = useCallback(() => {
|
||||
const sessionOption = options.find(o => o.option.type === 'accept-session')
|
||||
if (sessionOption) {
|
||||
const parsedInput = parseInput(toolUseConfirm.input)
|
||||
onChange(sessionOption.option, parsedInput)
|
||||
}
|
||||
}, [options, parseInput, toolUseConfirm.input, onChange])
|
||||
|
||||
// Register keyboard shortcut handler via keybindings system
|
||||
useKeybindings(
|
||||
{ 'confirm:cycleMode': handleCycleMode },
|
||||
{ context: 'Confirmation' },
|
||||
)
|
||||
|
||||
// Wrap setFocusedOption and reset input mode when navigating away
|
||||
const handleFocusedOptionChange = useCallback(
|
||||
(value: string) => {
|
||||
// Reset input mode when navigating away, but only if no text typed
|
||||
if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) {
|
||||
setYesInputMode(false)
|
||||
}
|
||||
if (value !== 'no' && noInputMode && !rejectFeedback.trim()) {
|
||||
setNoInputMode(false)
|
||||
}
|
||||
setFocusedOption(value)
|
||||
},
|
||||
[yesInputMode, noInputMode, acceptFeedback, rejectFeedback],
|
||||
)
|
||||
|
||||
// Handle Tab key toggling input mode for Yes/No options
|
||||
const handleInputModeToggle = useCallback(
|
||||
(value: string) => {
|
||||
const analyticsProps = {
|
||||
toolName: sanitizeToolNameForAnalytics(
|
||||
toolUseConfirm.tool.name,
|
||||
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
isMcp: toolUseConfirm.tool.isMcp ?? false,
|
||||
}
|
||||
|
||||
if (value === 'yes') {
|
||||
if (yesInputMode) {
|
||||
setYesInputMode(false)
|
||||
logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps)
|
||||
} else {
|
||||
setYesInputMode(true)
|
||||
setYesFeedbackModeEntered(true)
|
||||
logEvent('tengu_accept_feedback_mode_entered', analyticsProps)
|
||||
}
|
||||
} else if (value === 'no') {
|
||||
if (noInputMode) {
|
||||
setNoInputMode(false)
|
||||
logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps)
|
||||
} else {
|
||||
setNoInputMode(true)
|
||||
setNoFeedbackModeEntered(true)
|
||||
logEvent('tengu_reject_feedback_mode_entered', analyticsProps)
|
||||
}
|
||||
}
|
||||
},
|
||||
[yesInputMode, noInputMode, toolUseConfirm],
|
||||
)
|
||||
|
||||
return {
|
||||
options,
|
||||
onChange,
|
||||
acceptFeedback,
|
||||
rejectFeedback,
|
||||
focusedOption,
|
||||
setFocusedOption: handleFocusedOptionChange,
|
||||
handleInputModeToggle,
|
||||
yesInputMode,
|
||||
noInputMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type ScheduledTaskWizardData = {
|
||||
name?: string
|
||||
description?: string
|
||||
prompt?: string
|
||||
model?: string
|
||||
permissionMode?: string
|
||||
folder?: string
|
||||
worktree?: boolean
|
||||
frequency?: string
|
||||
scheduledTime?: string
|
||||
cron?: string
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import * as React from 'react';
|
||||
import { useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Context to indicate that shell output should be shown in full (not truncated).
|
||||
* Used to auto-expand the most recent user `!` command output.
|
||||
*
|
||||
* This follows the same pattern as MessageResponseContext and SubAgentContext -
|
||||
* a boolean context that child components can check to modify their behavior.
|
||||
*/
|
||||
const ExpandShellOutputContext = React.createContext(false);
|
||||
export function ExpandShellOutputProvider(t0) {
|
||||
const $ = _c(2);
|
||||
const {
|
||||
children
|
||||
} = t0;
|
||||
let t1;
|
||||
if ($[0] !== children) {
|
||||
t1 = <ExpandShellOutputContext.Provider value={true}>{children}</ExpandShellOutputContext.Provider>;
|
||||
$[0] = children;
|
||||
$[1] = t1;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
}
|
||||
return t1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this component is rendered inside an ExpandShellOutputProvider,
|
||||
* indicating the shell output should be shown in full rather than truncated.
|
||||
*/
|
||||
export function useExpandShellOutput() {
|
||||
return useContext(ExpandShellOutputContext);
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNvbnRleHQiLCJFeHBhbmRTaGVsbE91dHB1dENvbnRleHQiLCJjcmVhdGVDb250ZXh0IiwiRXhwYW5kU2hlbGxPdXRwdXRQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInVzZUV4cGFuZFNoZWxsT3V0cHV0Il0sInNvdXJjZXMiOlsiRXhwYW5kU2hlbGxPdXRwdXRDb250ZXh0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUNvbnRleHQgfSBmcm9tICdyZWFjdCdcblxuLyoqXG4gKiBDb250ZXh0IHRvIGluZGljYXRlIHRoYXQgc2hlbGwgb3V0cHV0IHNob3VsZCBiZSBzaG93biBpbiBmdWxsIChub3QgdHJ1bmNhdGVkKS5cbiAqIFVzZWQgdG8gYXV0by1leHBhbmQgdGhlIG1vc3QgcmVjZW50IHVzZXIgYCFgIGNvbW1hbmQgb3V0cHV0LlxuICpcbiAqIFRoaXMgZm9sbG93cyB0aGUgc2FtZSBwYXR0ZXJuIGFzIE1lc3NhZ2VSZXNwb25zZUNvbnRleHQgYW5kIFN1YkFnZW50Q29udGV4dCAtXG4gKiBhIGJvb2xlYW4gY29udGV4dCB0aGF0IGNoaWxkIGNvbXBvbmVudHMgY2FuIGNoZWNrIHRvIG1vZGlmeSB0aGVpciBiZWhhdmlvci5cbiAqL1xuY29uc3QgRXhwYW5kU2hlbGxPdXRwdXRDb250ZXh0ID0gUmVhY3QuY3JlYXRlQ29udGV4dChmYWxzZSlcblxuZXhwb3J0IGZ1bmN0aW9uIEV4cGFuZFNoZWxsT3V0cHV0UHJvdmlkZXIoe1xuICBjaGlsZHJlbixcbn06IHtcbiAgY2hpbGRyZW46IFJlYWN0LlJlYWN0Tm9kZVxufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIHJldHVybiAoXG4gICAgPEV4cGFuZFNoZWxsT3V0cHV0Q29udGV4dC5Qcm92aWRlciB2YWx1ZT17dHJ1ZX0+XG4gICAgICB7Y2hpbGRyZW59XG4gICAgPC9FeHBhbmRTaGVsbE91dHB1dENvbnRleHQuUHJvdmlkZXI+XG4gIClcbn1cblxuLyoqXG4gKiBSZXR1cm5zIHRydWUgaWYgdGhpcyBjb21wb25lbnQgaXMgcmVuZGVyZWQgaW5zaWRlIGFuIEV4cGFuZFNoZWxsT3V0cHV0UHJvdmlkZXIsXG4gKiBpbmRpY2F0aW5nIHRoZSBzaGVsbCBvdXRwdXQgc2hvdWxkIGJlIHNob3duIGluIGZ1bGwgcmF0aGVyIHRoYW4gdHJ1bmNhdGVkLlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlRXhwYW5kU2hlbGxPdXRwdXQoKTogYm9vbGVhbiB7XG4gIHJldHVybiB1c2VDb250ZXh0KEV4cGFuZFNoZWxsT3V0cHV0Q29udGV4dClcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsVUFBVSxRQUFRLE9BQU87O0FBRWxDO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsTUFBTUMsd0JBQXdCLEdBQUdGLEtBQUssQ0FBQ0csYUFBYSxDQUFDLEtBQUssQ0FBQztBQUUzRCxPQUFPLFNBQUFDLDBCQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQW1DO0lBQUFDO0VBQUEsSUFBQUgsRUFJekM7RUFBQSxJQUFBSSxFQUFBO0VBQUEsSUFBQUgsQ0FBQSxRQUFBRSxRQUFBO0lBRUdDLEVBQUEsc0NBQTBDLEtBQUksQ0FBSixLQUFHLENBQUMsQ0FDM0NELFNBQU8sQ0FDVixvQ0FBb0M7SUFBQUYsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBQUEsT0FGcENHLEVBRW9DO0FBQUE7O0FBSXhDO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxxQkFBQTtFQUFBLE9BQ0VULFVBQVUsQ0FBQ0Msd0JBQXdCLENBQUM7QUFBQSIsImlnbm9yZUxpc3QiOltdfQ==
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
WizardContextValue,
|
||||
WizardProviderProps,
|
||||
WizardStepComponent,
|
||||
} from './types.js'
|
||||
export { useWizard } from './useWizard.js'
|
||||
export { WizardDialogLayout } from './WizardDialogLayout.js'
|
||||
export { WizardNavigationFooter } from './WizardNavigationFooter.js'
|
||||
export { WizardProvider } from './WizardProvider.js'
|
||||
@@ -0,0 +1,144 @@
|
||||
export const PR_TITLE = 'Add Claude Code GitHub Workflow'
|
||||
|
||||
export const GITHUB_ACTION_SETUP_DOCS_URL =
|
||||
'https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md'
|
||||
|
||||
export const WORKFLOW_CONTENT = `name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
`
|
||||
|
||||
export const PR_BODY = `## 🤖 Installing Claude Code GitHub App
|
||||
|
||||
This PR adds a GitHub Actions workflow that enables Claude Code integration in our repository.
|
||||
|
||||
### What is Claude Code?
|
||||
|
||||
[Claude Code](https://claude.com/claude-code) is an AI coding agent that can help with:
|
||||
- Bug fixes and improvements
|
||||
- Documentation updates
|
||||
- Implementing new features
|
||||
- Code reviews and suggestions
|
||||
- Writing tests
|
||||
- And more!
|
||||
|
||||
### How it works
|
||||
|
||||
Once this PR is merged, we'll be able to interact with Claude by mentioning @claude in a pull request or issue comment.
|
||||
Once the workflow is triggered, Claude will analyze the comment and surrounding context, and execute on the request in a GitHub action.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **This workflow won't take effect until this PR is merged**
|
||||
- **@claude mentions won't work until after the merge is complete**
|
||||
- The workflow runs automatically whenever Claude is mentioned in PR or issue comments
|
||||
- Claude gets access to the entire PR or issue context including files, diffs, and previous comments
|
||||
|
||||
### Security
|
||||
|
||||
- Our Anthropic API key is securely stored as a GitHub Actions secret
|
||||
- Only users with write access to the repository can trigger the workflow
|
||||
- All Claude runs are stored in the GitHub Actions run history
|
||||
- Claude's default tools are limited to reading/writing files and interacting with our repo by creating comments, branches, and commits.
|
||||
- We can add more allowed tools by adding them to the workflow file like:
|
||||
|
||||
\`\`\`
|
||||
allowed_tools: Bash(npm install),Bash(npm run build),Bash(npm run lint),Bash(npm run test)
|
||||
\`\`\`
|
||||
|
||||
There's more information in the [Claude Code action repo](https://github.com/anthropics/claude-code-action).
|
||||
|
||||
After merging this PR, let's try mentioning @claude in a comment on any PR to get started!`
|
||||
|
||||
export const CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT = `name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review \${{ github.repository }}/pull/\${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
`
|
||||
@@ -0,0 +1,3 @@
|
||||
// Local recovery stub for missing generated SDK types.
|
||||
// The leaked source tree does not include this codegen artifact.
|
||||
export {}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNotifications } from 'src/context/notifications.js';
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js';
|
||||
import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js';
|
||||
import type { ValidationError } from '../../utils/settings/validation.js';
|
||||
import { useSettingsChange } from '../useSettingsChange.js';
|
||||
const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors';
|
||||
export function useSettingsErrors() {
|
||||
const $ = _c(6);
|
||||
const {
|
||||
addNotification,
|
||||
removeNotification
|
||||
} = useNotifications();
|
||||
const [errors_0, setErrors] = useState(_temp);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = () => {
|
||||
const {
|
||||
errors: errors_1
|
||||
} = getSettingsWithAllErrors();
|
||||
setErrors(errors_1);
|
||||
};
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const handleSettingsChange = t0;
|
||||
useSettingsChange(handleSettingsChange);
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[1] !== addNotification || $[2] !== errors_0 || $[3] !== removeNotification) {
|
||||
t1 = () => {
|
||||
if (getIsRemoteMode()) {
|
||||
return;
|
||||
}
|
||||
if (errors_0.length > 0) {
|
||||
const message = `Found ${errors_0.length} settings ${errors_0.length === 1 ? "issue" : "issues"} · /doctor for details`;
|
||||
addNotification({
|
||||
key: SETTINGS_ERRORS_NOTIFICATION_KEY,
|
||||
text: message,
|
||||
color: "warning",
|
||||
priority: "high",
|
||||
timeoutMs: 60000
|
||||
});
|
||||
} else {
|
||||
removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY);
|
||||
}
|
||||
};
|
||||
t2 = [errors_0, addNotification, removeNotification];
|
||||
$[1] = addNotification;
|
||||
$[2] = errors_0;
|
||||
$[3] = removeNotification;
|
||||
$[4] = t1;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
t1 = $[4];
|
||||
t2 = $[5];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
return errors_0;
|
||||
}
|
||||
function _temp() {
|
||||
const {
|
||||
errors
|
||||
} = getSettingsWithAllErrors();
|
||||
return errors;
|
||||
}
|
||||
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwidXNlTm90aWZpY2F0aW9ucyIsImdldElzUmVtb3RlTW9kZSIsImdldFNldHRpbmdzV2l0aEFsbEVycm9ycyIsIlZhbGlkYXRpb25FcnJvciIsInVzZVNldHRpbmdzQ2hhbmdlIiwiU0VUVElOR1NfRVJST1JTX05PVElGSUNBVElPTl9LRVkiLCJ1c2VTZXR0aW5nc0Vycm9ycyIsIiQiLCJfYyIsImFkZE5vdGlmaWNhdGlvbiIsInJlbW92ZU5vdGlmaWNhdGlvbiIsImVycm9yc18wIiwic2V0RXJyb3JzIiwiX3RlbXAiLCJ0MCIsIlN5bWJvbCIsImZvciIsImVycm9ycyIsImVycm9yc18xIiwiaGFuZGxlU2V0dGluZ3NDaGFuZ2UiLCJ0MSIsInQyIiwibGVuZ3RoIiwibWVzc2FnZSIsImtleSIsInRleHQiLCJjb2xvciIsInByaW9yaXR5IiwidGltZW91dE1zIl0sInNvdXJjZXMiOlsidXNlU2V0dGluZ3NFcnJvcnMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHVzZUNhbGxiYWNrLCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnc3JjL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IGdldFNldHRpbmdzV2l0aEFsbEVycm9ycyB9IGZyb20gJy4uLy4uL3V0aWxzL3NldHRpbmdzL2FsbEVycm9ycy5qcydcbmltcG9ydCB0eXBlIHsgVmFsaWRhdGlvbkVycm9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvc2V0dGluZ3MvdmFsaWRhdGlvbi5qcydcbmltcG9ydCB7IHVzZVNldHRpbmdzQ2hhbmdlIH0gZnJvbSAnLi4vdXNlU2V0dGluZ3NDaGFuZ2UuanMnXG5cbmNvbnN0IFNFVFRJTkdTX0VSUk9SU19OT1RJRklDQVRJT05fS0VZID0gJ3NldHRpbmdzLWVycm9ycydcblxuZXhwb3J0IGZ1bmN0aW9uIHVzZVNldHRpbmdzRXJyb3JzKCk6IFZhbGlkYXRpb25FcnJvcltdIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IFtlcnJvcnMsIHNldEVycm9yc10gPSB1c2VTdGF0ZTxWYWxpZGF0aW9uRXJyb3JbXT4oKCkgPT4ge1xuICAgIGNvbnN0IHsgZXJyb3JzIH0gPSBnZXRTZXR0aW5nc1dpdGhBbGxFcnJvcnMoKVxuICAgIHJldHVybiBlcnJvcnNcbiAgfSlcblxuICBjb25zdCBoYW5kbGVTZXR0aW5nc0NoYW5nZSA9IHVzZUNhbGxiYWNrKCgpID0+IHtcbiAgICBjb25zdCB7IGVycm9ycyB9ID0gZ2V0U2V0dGluZ3NXaXRoQWxsRXJyb3JzKClcbiAgICBzZXRFcnJvcnMoZXJyb3JzKVxuICB9LCBbXSlcblxuICB1c2VTZXR0aW5nc0NoYW5nZShoYW5kbGVTZXR0aW5nc0NoYW5nZSlcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmIChnZXRJc1JlbW90ZU1vZGUoKSkgcmV0dXJuXG4gICAgaWYgKGVycm9ycy5sZW5ndGggPiAwKSB7XG4gICAgICBjb25zdCBtZXNzYWdlID0gYEZvdW5kICR7ZXJyb3JzLmxlbmd0aH0gc2V0dGluZ3MgJHtlcnJvcnMubGVuZ3RoID09PSAxID8gJ2lzc3VlJyA6ICdpc3N1ZXMnfSDCtyAvZG9jdG9yIGZvciBkZXRhaWxzYFxuICAgICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgICAga2V5OiBTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSxcbiAgICAgICAgdGV4dDogbWVzc2FnZSxcbiAgICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgICAgcHJpb3JpdHk6ICdoaWdoJyxcbiAgICAgICAgdGltZW91dE1zOiA2MDAwMCxcbiAgICAgIH0pXG4gICAgfSBlbHNlIHtcbiAgICAgIHJlbW92ZU5vdGlmaWNhdGlvbihTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSlcbiAgICB9XG4gIH0sIFtlcnJvcnMsIGFkZE5vdGlmaWNhdGlvbiwgcmVtb3ZlTm90aWZpY2F0aW9uXSlcblxuICByZXR1cm4gZXJyb3JzXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxTQUFTQSxXQUFXLEVBQUVDLFNBQVMsRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDeEQsU0FBU0MsZ0JBQWdCLFFBQVEsOEJBQThCO0FBQy9ELFNBQVNDLGVBQWUsUUFBUSwwQkFBMEI7QUFDMUQsU0FBU0Msd0JBQXdCLFFBQVEsbUNBQW1DO0FBQzVFLGNBQWNDLGVBQWUsUUFBUSxvQ0FBb0M7QUFDekUsU0FBU0MsaUJBQWlCLFFBQVEseUJBQXlCO0FBRTNELE1BQU1DLGdDQUFnQyxHQUFHLGlCQUFpQjtBQUUxRCxPQUFPLFNBQUFDLGtCQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQ0w7SUFBQUMsZUFBQTtJQUFBQztFQUFBLElBQWdEVixnQkFBZ0IsQ0FBQyxDQUFDO0VBQ2xFLE9BQUFXLFFBQUEsRUFBQUMsU0FBQSxJQUE0QmIsUUFBUSxDQUFvQmMsS0FHdkQsQ0FBQztFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBUCxDQUFBLFFBQUFRLE1BQUEsQ0FBQUMsR0FBQTtJQUV1Q0YsRUFBQSxHQUFBQSxDQUFBO01BQ3ZDO1FBQUFHLE1BQUEsRUFBQUM7TUFBQSxJQUFtQmhCLHdCQUF3QixDQUFDLENBQUM7TUFDN0NVLFNBQVMsQ0FBQ0ssUUFBTSxDQUFDO0lBQUEsQ0FDbEI7SUFBQVYsQ0FBQSxNQUFBTyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUCxDQUFBO0VBQUE7RUFIRCxNQUFBWSxvQkFBQSxHQUE2QkwsRUFHdkI7RUFFTlYsaUJBQWlCLENBQUNlLG9CQUFvQixDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBZCxDQUFBLFFBQUFFLGVBQUEsSUFBQUYsQ0FBQSxRQUFBSSxRQUFBLElBQUFKLENBQUEsUUFBQUcsa0JBQUE7SUFFN0JVLEVBQUEsR0FBQUEsQ0FBQTtNQUNSLElBQUluQixlQUFlLENBQUMsQ0FBQztRQUFBO01BQUE7TUFDckIsSUFBSWdCLFFBQU0sQ0FBQUssTUFBTyxHQUFHLENBQUM7UUFDbkIsTUFBQUMsT0FBQSxHQUFnQixTQUFTTixRQUFNLENBQUFLLE1BQU8sYUFBYUwsUUFBTSxDQUFBSyxNQUFPLEtBQUssQ0FBc0IsR0FBeEMsT0FBd0MsR0FBeEMsUUFBd0Msd0JBQXdCO1FBQ25IYixlQUFlLENBQUM7VUFBQWUsR0FBQSxFQUNUbkIsZ0NBQWdDO1VBQUFvQixJQUFBLEVBQy9CRixPQUFPO1VBQUFHLEtBQUEsRUFDTixTQUFTO1VBQUFDLFFBQUEsRUFDTixNQUFNO1VBQUFDLFNBQUEsRUFDTDtRQUNiLENBQUMsQ0FBQztNQUFBO1FBRUZsQixrQkFBa0IsQ0FBQ0wsZ0NBQWdDLENBQUM7TUFBQTtJQUNyRCxDQUNGO0lBQUVnQixFQUFBLElBQUNKLFFBQU0sRUFBRVIsZUFBZSxFQUFFQyxrQkFBa0IsQ0FBQztJQUFBSCxDQUFBLE1BQUFFLGVBQUE7SUFBQUYsQ0FBQSxNQUFBSSxRQUFBO0lBQUFKLENBQUEsTUFBQUcsa0JBQUE7SUFBQUgsQ0FBQSxNQUFBYSxFQUFBO0lBQUFiLENBQUEsTUFBQWMsRUFBQTtFQUFBO0lBQUFELEVBQUEsR0FBQWIsQ0FBQTtJQUFBYyxFQUFBLEdBQUFkLENBQUE7RUFBQTtFQWRoRFQsU0FBUyxDQUFDc0IsRUFjVCxFQUFFQyxFQUE2QyxDQUFDO0VBQUEsT0FFMUNKLFFBQU07QUFBQTtBQTlCUixTQUFBSixNQUFBO0VBR0g7SUFBQUk7RUFBQSxJQUFtQmYsd0JBQXdCLENBQUMsQ0FBQztFQUFBLE9BQ3RDZSxNQUFNO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { getIsRemoteMode } from '../../bootstrap/state.js'
|
||||
import {
|
||||
type Notification,
|
||||
useNotifications,
|
||||
} from '../../context/notifications.js'
|
||||
import { logError } from '../../utils/log.js'
|
||||
|
||||
type Result = Notification | Notification[] | null
|
||||
|
||||
/**
|
||||
* Fires notification(s) once on mount. Encapsulates the remote-mode gate and
|
||||
* once-per-session ref guard that was hand-rolled across 10+ notifs/ hooks.
|
||||
*
|
||||
* The compute fn runs exactly once on first effect. Return null to skip,
|
||||
* a Notification to fire one, or an array to fire several. Sync or async.
|
||||
* Rejections are routed to logError.
|
||||
*/
|
||||
export function useStartupNotification(
|
||||
compute: () => Result | Promise<Result>,
|
||||
): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const hasRunRef = useRef(false)
|
||||
const computeRef = useRef(compute)
|
||||
computeRef.current = compute
|
||||
|
||||
useEffect(() => {
|
||||
if (getIsRemoteMode() || hasRunRef.current) return
|
||||
hasRunRef.current = true
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => computeRef.current())
|
||||
.then(result => {
|
||||
if (!result) return
|
||||
for (const n of Array.isArray(result) ? result : [result]) {
|
||||
addNotification(n)
|
||||
}
|
||||
})
|
||||
.catch(logError)
|
||||
}, [addNotification])
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import chalk from 'chalk'
|
||||
|
||||
type PlaceholderRendererProps = {
|
||||
placeholder?: string
|
||||
value: string
|
||||
showCursor?: boolean
|
||||
focus?: boolean
|
||||
terminalFocus: boolean
|
||||
invert?: (text: string) => string
|
||||
hidePlaceholderText?: boolean
|
||||
}
|
||||
|
||||
export function renderPlaceholder({
|
||||
placeholder,
|
||||
value,
|
||||
showCursor,
|
||||
focus,
|
||||
terminalFocus = true,
|
||||
invert = chalk.inverse,
|
||||
hidePlaceholderText = false,
|
||||
}: PlaceholderRendererProps): {
|
||||
renderedPlaceholder: string | undefined
|
||||
showPlaceholder: boolean
|
||||
} {
|
||||
let renderedPlaceholder: string | undefined = undefined
|
||||
|
||||
if (placeholder) {
|
||||
if (hidePlaceholderText) {
|
||||
// Voice recording: show only the cursor, no placeholder text
|
||||
renderedPlaceholder =
|
||||
showCursor && focus && terminalFocus ? invert(' ') : ''
|
||||
} else {
|
||||
renderedPlaceholder = chalk.dim(placeholder)
|
||||
|
||||
// Show inverse cursor only when both input and terminal are focused
|
||||
if (showCursor && focus && terminalFocus) {
|
||||
renderedPlaceholder =
|
||||
placeholder.length > 0
|
||||
? invert(placeholder[0]!) + chalk.dim(placeholder.slice(1))
|
||||
: invert(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const showPlaceholder = value.length === 0 && Boolean(placeholder)
|
||||
|
||||
return {
|
||||
renderedPlaceholder,
|
||||
showPlaceholder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
|
||||
import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
|
||||
import type {
|
||||
ToolPermissionContext,
|
||||
Tool as ToolType,
|
||||
ToolUseContext,
|
||||
} from '../../Tool.js'
|
||||
import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
|
||||
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
|
||||
import type { AssistantMessage } from '../../types/message.js'
|
||||
import type {
|
||||
PendingClassifierCheck,
|
||||
PermissionAllowDecision,
|
||||
PermissionDecisionReason,
|
||||
PermissionDenyDecision,
|
||||
} from '../../types/permissions.js'
|
||||
import { setClassifierApproval } from '../../utils/classifierApprovals.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { executePermissionRequestHooks } from '../../utils/hooks.js'
|
||||
import {
|
||||
REJECT_MESSAGE,
|
||||
REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
SUBAGENT_REJECT_MESSAGE,
|
||||
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
|
||||
withMemoryCorrectionHint,
|
||||
} from '../../utils/messages.js'
|
||||
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
|
||||
import {
|
||||
applyPermissionUpdates,
|
||||
persistPermissionUpdates,
|
||||
supportsPersistence,
|
||||
} from '../../utils/permissions/PermissionUpdate.js'
|
||||
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
|
||||
import {
|
||||
logPermissionDecision,
|
||||
type PermissionDecisionArgs,
|
||||
} from './permissionLogging.js'
|
||||
|
||||
type PermissionApprovalSource =
|
||||
| { type: 'hook'; permanent?: boolean }
|
||||
| { type: 'user'; permanent: boolean }
|
||||
| { type: 'classifier' }
|
||||
|
||||
type PermissionRejectionSource =
|
||||
| { type: 'hook' }
|
||||
| { type: 'user_abort' }
|
||||
| { type: 'user_reject'; hasFeedback: boolean }
|
||||
|
||||
// Generic interface for permission queue operations, decoupled from React.
|
||||
// In the REPL, these are backed by React state.
|
||||
type PermissionQueueOps = {
|
||||
push(item: ToolUseConfirm): void
|
||||
remove(toolUseID: string): void
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
|
||||
}
|
||||
|
||||
type ResolveOnce<T> = {
|
||||
resolve(value: T): void
|
||||
isResolved(): boolean
|
||||
/**
|
||||
* Atomically check-and-mark as resolved. Returns true if this caller
|
||||
* won the race (nobody else has resolved yet), false otherwise.
|
||||
* Use this in async callbacks BEFORE awaiting, to close the window
|
||||
* between the `isResolved()` check and the actual `resolve()` call.
|
||||
*/
|
||||
claim(): boolean
|
||||
}
|
||||
|
||||
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
|
||||
let claimed = false
|
||||
let delivered = false
|
||||
return {
|
||||
resolve(value: T) {
|
||||
if (delivered) return
|
||||
delivered = true
|
||||
claimed = true
|
||||
resolve(value)
|
||||
},
|
||||
isResolved() {
|
||||
return claimed
|
||||
},
|
||||
claim() {
|
||||
if (claimed) return false
|
||||
claimed = true
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createPermissionContext(
|
||||
tool: ToolType,
|
||||
input: Record<string, unknown>,
|
||||
toolUseContext: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
setToolPermissionContext: (context: ToolPermissionContext) => void,
|
||||
queueOps?: PermissionQueueOps,
|
||||
) {
|
||||
const messageId = assistantMessage.message.id
|
||||
const ctx = {
|
||||
tool,
|
||||
input,
|
||||
toolUseContext,
|
||||
assistantMessage,
|
||||
messageId,
|
||||
toolUseID,
|
||||
logDecision(
|
||||
args: PermissionDecisionArgs,
|
||||
opts?: {
|
||||
input?: Record<string, unknown>
|
||||
permissionPromptStartTimeMs?: number
|
||||
},
|
||||
) {
|
||||
logPermissionDecision(
|
||||
{
|
||||
tool,
|
||||
input: opts?.input ?? input,
|
||||
toolUseContext,
|
||||
messageId,
|
||||
toolUseID,
|
||||
},
|
||||
args,
|
||||
opts?.permissionPromptStartTimeMs,
|
||||
)
|
||||
},
|
||||
logCancelled() {
|
||||
logEvent('tengu_tool_use_cancelled', {
|
||||
messageID:
|
||||
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName: sanitizeToolNameForAnalytics(tool.name),
|
||||
})
|
||||
},
|
||||
async persistPermissions(updates: PermissionUpdate[]) {
|
||||
if (updates.length === 0) return false
|
||||
persistPermissionUpdates(updates)
|
||||
const appState = toolUseContext.getAppState()
|
||||
setToolPermissionContext(
|
||||
applyPermissionUpdates(appState.toolPermissionContext, updates),
|
||||
)
|
||||
return updates.some(update => supportsPersistence(update.destination))
|
||||
},
|
||||
resolveIfAborted(resolve: (decision: PermissionDecision) => void) {
|
||||
if (!toolUseContext.abortController.signal.aborted) return false
|
||||
this.logCancelled()
|
||||
resolve(this.cancelAndAbort(undefined, true))
|
||||
return true
|
||||
},
|
||||
cancelAndAbort(
|
||||
feedback?: string,
|
||||
isAbort?: boolean,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
): PermissionDecision {
|
||||
const sub = !!toolUseContext.agentId
|
||||
const baseMessage = feedback
|
||||
? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
|
||||
: sub
|
||||
? SUBAGENT_REJECT_MESSAGE
|
||||
: REJECT_MESSAGE
|
||||
const message = sub ? baseMessage : withMemoryCorrectionHint(baseMessage)
|
||||
if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
|
||||
logForDebugging(
|
||||
`Aborting: tool=${tool.name} isAbort=${isAbort} hasFeedback=${!!feedback} isSubagent=${sub}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return { behavior: 'ask', message, contentBlocks }
|
||||
},
|
||||
...(feature('BASH_CLASSIFIER')
|
||||
? {
|
||||
async tryClassifier(
|
||||
pendingClassifierCheck: PendingClassifierCheck | undefined,
|
||||
updatedInput: Record<string, unknown> | undefined,
|
||||
): Promise<PermissionDecision | null> {
|
||||
if (tool.name !== BASH_TOOL_NAME || !pendingClassifierCheck) {
|
||||
return null
|
||||
}
|
||||
const classifierDecision = await awaitClassifierAutoApproval(
|
||||
pendingClassifierCheck,
|
||||
toolUseContext.abortController.signal,
|
||||
toolUseContext.options.isNonInteractiveSession,
|
||||
)
|
||||
if (!classifierDecision) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
feature('TRANSCRIPT_CLASSIFIER') &&
|
||||
classifierDecision.type === 'classifier'
|
||||
) {
|
||||
const matchedRule = classifierDecision.reason.match(
|
||||
/^Allowed by prompt rule: "(.+)"$/,
|
||||
)?.[1]
|
||||
if (matchedRule) {
|
||||
setClassifierApproval(toolUseID, matchedRule)
|
||||
}
|
||||
}
|
||||
logPermissionDecision(
|
||||
{ tool, input, toolUseContext, messageId, toolUseID },
|
||||
{ decision: 'accept', source: { type: 'classifier' } },
|
||||
undefined,
|
||||
)
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: updatedInput ?? input,
|
||||
userModified: false,
|
||||
decisionReason: classifierDecision,
|
||||
}
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
async runHooks(
|
||||
permissionMode: string | undefined,
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
updatedInput?: Record<string, unknown>,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionDecision | null> {
|
||||
for await (const hookResult of executePermissionRequestHooks(
|
||||
tool.name,
|
||||
toolUseID,
|
||||
input,
|
||||
toolUseContext,
|
||||
permissionMode,
|
||||
suggestions,
|
||||
toolUseContext.abortController.signal,
|
||||
)) {
|
||||
if (hookResult.permissionRequestResult) {
|
||||
const decision = hookResult.permissionRequestResult
|
||||
if (decision.behavior === 'allow') {
|
||||
const finalInput = decision.updatedInput ?? updatedInput ?? input
|
||||
return await this.handleHookAllow(
|
||||
finalInput,
|
||||
decision.updatedPermissions ?? [],
|
||||
permissionPromptStartTimeMs,
|
||||
)
|
||||
} else if (decision.behavior === 'deny') {
|
||||
this.logDecision(
|
||||
{ decision: 'reject', source: { type: 'hook' } },
|
||||
{ permissionPromptStartTimeMs },
|
||||
)
|
||||
if (decision.interrupt) {
|
||||
logForDebugging(
|
||||
`Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
|
||||
)
|
||||
toolUseContext.abortController.abort()
|
||||
}
|
||||
return this.buildDeny(
|
||||
decision.message || 'Permission denied by hook',
|
||||
{
|
||||
type: 'hook',
|
||||
hookName: 'PermissionRequest',
|
||||
reason: decision.message,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
buildAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
opts?: {
|
||||
userModified?: boolean
|
||||
decisionReason?: PermissionDecisionReason
|
||||
acceptFeedback?: string
|
||||
contentBlocks?: ContentBlockParam[]
|
||||
},
|
||||
): PermissionAllowDecision {
|
||||
return {
|
||||
behavior: 'allow' as const,
|
||||
updatedInput,
|
||||
userModified: opts?.userModified ?? false,
|
||||
...(opts?.decisionReason && { decisionReason: opts.decisionReason }),
|
||||
...(opts?.acceptFeedback && { acceptFeedback: opts.acceptFeedback }),
|
||||
...(opts?.contentBlocks &&
|
||||
opts.contentBlocks.length > 0 && {
|
||||
contentBlocks: opts.contentBlocks,
|
||||
}),
|
||||
}
|
||||
},
|
||||
buildDeny(
|
||||
message: string,
|
||||
decisionReason: PermissionDecisionReason,
|
||||
): PermissionDenyDecision {
|
||||
return { behavior: 'deny' as const, message, decisionReason }
|
||||
},
|
||||
async handleUserAllow(
|
||||
updatedInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
feedback?: string,
|
||||
permissionPromptStartTimeMs?: number,
|
||||
contentBlocks?: ContentBlockParam[],
|
||||
decisionReason?: PermissionDecisionReason,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'user', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: updatedInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
const userModified = tool.inputsEquivalent
|
||||
? !tool.inputsEquivalent(input, updatedInput)
|
||||
: false
|
||||
const trimmedFeedback = feedback?.trim()
|
||||
return this.buildAllow(updatedInput, {
|
||||
userModified,
|
||||
decisionReason,
|
||||
acceptFeedback: trimmedFeedback || undefined,
|
||||
contentBlocks,
|
||||
})
|
||||
},
|
||||
async handleHookAllow(
|
||||
finalInput: Record<string, unknown>,
|
||||
permissionUpdates: PermissionUpdate[],
|
||||
permissionPromptStartTimeMs?: number,
|
||||
): Promise<PermissionAllowDecision> {
|
||||
const acceptedPermanentUpdates =
|
||||
await this.persistPermissions(permissionUpdates)
|
||||
this.logDecision(
|
||||
{
|
||||
decision: 'accept',
|
||||
source: { type: 'hook', permanent: acceptedPermanentUpdates },
|
||||
},
|
||||
{ input: finalInput, permissionPromptStartTimeMs },
|
||||
)
|
||||
return this.buildAllow(finalInput, {
|
||||
decisionReason: { type: 'hook', hookName: 'PermissionRequest' },
|
||||
})
|
||||
},
|
||||
pushToQueue(item: ToolUseConfirm) {
|
||||
queueOps?.push(item)
|
||||
},
|
||||
removeFromQueue() {
|
||||
queueOps?.remove(toolUseID)
|
||||
},
|
||||
updateQueueItem(patch: Partial<ToolUseConfirm>) {
|
||||
queueOps?.update(toolUseID, patch)
|
||||
},
|
||||
}
|
||||
return Object.freeze(ctx)
|
||||
}
|
||||
|
||||
type PermissionContext = ReturnType<typeof createPermissionContext>
|
||||
|
||||
/**
|
||||
* Create a PermissionQueueOps backed by a React state setter.
|
||||
* This is the bridge between React's `setToolUseConfirmQueue` and the
|
||||
* generic queue interface used by PermissionContext.
|
||||
*/
|
||||
function createPermissionQueueOps(
|
||||
setToolUseConfirmQueue: React.Dispatch<
|
||||
React.SetStateAction<ToolUseConfirm[]>
|
||||
>,
|
||||
): PermissionQueueOps {
|
||||
return {
|
||||
push(item: ToolUseConfirm) {
|
||||
setToolUseConfirmQueue(queue => [...queue, item])
|
||||
},
|
||||
remove(toolUseID: string) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.filter(item => item.toolUseID !== toolUseID),
|
||||
)
|
||||
},
|
||||
update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
|
||||
setToolUseConfirmQueue(queue =>
|
||||
queue.map(item =>
|
||||
item.toolUseID === toolUseID ? { ...item, ...patch } : item,
|
||||
),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
|
||||
export type {
|
||||
PermissionContext,
|
||||
PermissionApprovalSource,
|
||||
PermissionQueueOps,
|
||||
PermissionRejectionSource,
|
||||
ResolveOnce,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotifications } from '../context/notifications.js'
|
||||
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
|
||||
import { hasImageInClipboard } from '../utils/imagePaste.js'
|
||||
|
||||
const NOTIFICATION_KEY = 'clipboard-image-hint'
|
||||
// Small debounce to batch rapid focus changes
|
||||
const FOCUS_CHECK_DEBOUNCE_MS = 1000
|
||||
// Don't show the hint more than once per this interval
|
||||
const HINT_COOLDOWN_MS = 30000
|
||||
|
||||
/**
|
||||
* Hook that shows a notification when the terminal regains focus
|
||||
* and the clipboard contains an image.
|
||||
*
|
||||
* @param isFocused - Whether the terminal is currently focused
|
||||
* @param enabled - Whether image paste is enabled (onImagePaste is defined)
|
||||
*/
|
||||
export function useClipboardImageHint(
|
||||
isFocused: boolean,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const { addNotification } = useNotifications()
|
||||
const lastFocusedRef = useRef(isFocused)
|
||||
const lastHintTimeRef = useRef(0)
|
||||
const checkTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only trigger on focus regain (was unfocused, now focused)
|
||||
const wasFocused = lastFocusedRef.current
|
||||
lastFocusedRef.current = isFocused
|
||||
|
||||
if (!enabled || !isFocused || wasFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any pending check
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Small debounce to batch rapid focus changes
|
||||
checkTimeoutRef.current = setTimeout(
|
||||
async (checkTimeoutRef, lastHintTimeRef, addNotification) => {
|
||||
checkTimeoutRef.current = null
|
||||
|
||||
// Check cooldown to avoid spamming the user
|
||||
const now = Date.now()
|
||||
if (now - lastHintTimeRef.current < HINT_COOLDOWN_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clipboard has an image (async osascript call)
|
||||
if (await hasImageInClipboard()) {
|
||||
lastHintTimeRef.current = now
|
||||
addNotification({
|
||||
key: NOTIFICATION_KEY,
|
||||
text: `Image in clipboard · ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste`,
|
||||
priority: 'immediate',
|
||||
timeoutMs: 8000,
|
||||
})
|
||||
}
|
||||
},
|
||||
FOCUS_CHECK_DEBOUNCE_MS,
|
||||
checkTimeoutRef,
|
||||
lastHintTimeRef,
|
||||
addNotification,
|
||||
)
|
||||
|
||||
return () => {
|
||||
if (checkTimeoutRef.current) {
|
||||
clearTimeout(checkTimeoutRef.current)
|
||||
checkTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
}, [isFocused, enabled, addNotification])
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { StructuredPatchHunk } from 'diff'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
fetchGitDiff,
|
||||
fetchGitDiffHunks,
|
||||
type GitDiffResult,
|
||||
type GitDiffStats,
|
||||
} from '../utils/gitDiff.js'
|
||||
|
||||
const MAX_LINES_PER_FILE = 400
|
||||
|
||||
export type DiffFile = {
|
||||
path: string
|
||||
linesAdded: number
|
||||
linesRemoved: number
|
||||
isBinary: boolean
|
||||
isLargeFile: boolean
|
||||
isTruncated: boolean
|
||||
isNewFile?: boolean
|
||||
isUntracked?: boolean
|
||||
}
|
||||
|
||||
export type DiffData = {
|
||||
stats: GitDiffStats | null
|
||||
files: DiffFile[]
|
||||
hunks: Map<string, StructuredPatchHunk[]>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch current git diff data on demand.
|
||||
* Fetches both stats and hunks when component mounts.
|
||||
*/
|
||||
export function useDiffData(): DiffData {
|
||||
const [diffResult, setDiffResult] = useState<GitDiffResult | null>(null)
|
||||
const [hunks, setHunks] = useState<Map<string, StructuredPatchHunk[]>>(
|
||||
new Map(),
|
||||
)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// Fetch diff data on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadDiffData() {
|
||||
try {
|
||||
// Fetch both stats and hunks
|
||||
const [statsResult, hunksResult] = await Promise.all([
|
||||
fetchGitDiff(),
|
||||
fetchGitDiffHunks(),
|
||||
])
|
||||
|
||||
if (!cancelled) {
|
||||
setDiffResult(statsResult)
|
||||
setHunks(hunksResult)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (_error) {
|
||||
if (!cancelled) {
|
||||
setDiffResult(null)
|
||||
setHunks(new Map())
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadDiffData()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => {
|
||||
if (!diffResult) {
|
||||
return { stats: null, files: [], hunks: new Map(), loading }
|
||||
}
|
||||
|
||||
const { stats, perFileStats } = diffResult
|
||||
const files: DiffFile[] = []
|
||||
|
||||
// Iterate over perFileStats to get all files including large/skipped ones
|
||||
for (const [path, fileStats] of perFileStats) {
|
||||
const fileHunks = hunks.get(path)
|
||||
const isUntracked = fileStats.isUntracked ?? false
|
||||
|
||||
// Detect large file (in perFileStats but not in hunks, and not binary/untracked)
|
||||
const isLargeFile = !fileStats.isBinary && !isUntracked && !fileHunks
|
||||
|
||||
// Detect truncated file (total > limit means we truncated)
|
||||
const totalLines = fileStats.added + fileStats.removed
|
||||
const isTruncated =
|
||||
!isLargeFile && !fileStats.isBinary && totalLines > MAX_LINES_PER_FILE
|
||||
|
||||
files.push({
|
||||
path,
|
||||
linesAdded: fileStats.added,
|
||||
linesRemoved: fileStats.removed,
|
||||
isBinary: fileStats.isBinary,
|
||||
isLargeFile,
|
||||
isTruncated,
|
||||
isUntracked,
|
||||
})
|
||||
}
|
||||
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
|
||||
return { stats, files, hunks, loading: false }
|
||||
}, [diffResult, hunks, loading])
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useKeybindings } from '../keybindings/useKeybinding.js'
|
||||
import { type ExitState, useExitOnCtrlCD } from './useExitOnCtrlCD.js'
|
||||
|
||||
export type { ExitState }
|
||||
|
||||
/**
|
||||
* Convenience hook that wires up useExitOnCtrlCD with useKeybindings.
|
||||
*
|
||||
* This is the standard way to use useExitOnCtrlCD in components.
|
||||
* The separation exists to avoid import cycles - useExitOnCtrlCD.ts
|
||||
* doesn't import from the keybindings module directly.
|
||||
*
|
||||
* @param onExit - Optional custom exit handler
|
||||
* @param onInterrupt - Optional callback for features to handle interrupt (ctrl+c).
|
||||
* Return true if handled, false to fall through to double-press exit.
|
||||
* @param isActive - Whether the keybinding is active (default true).
|
||||
*/
|
||||
export function useExitOnCtrlCDWithKeybindings(
|
||||
onExit?: () => void,
|
||||
onInterrupt?: () => boolean,
|
||||
isActive?: boolean,
|
||||
): ExitState {
|
||||
return useExitOnCtrlCD(useKeybindings, onInterrupt, onExit, isActive)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
|
||||
export type IdeStatus = 'connected' | 'disconnected' | 'pending' | null
|
||||
|
||||
type IdeConnectionResult = {
|
||||
status: IdeStatus
|
||||
ideName: string | null
|
||||
}
|
||||
|
||||
export function useIdeConnectionStatus(
|
||||
mcpClients?: MCPServerConnection[],
|
||||
): IdeConnectionResult {
|
||||
return useMemo(() => {
|
||||
const ideClient = mcpClients?.find(client => client.name === 'ide')
|
||||
if (!ideClient) {
|
||||
return { status: null, ideName: null }
|
||||
}
|
||||
// Extract IDE name from config if available
|
||||
const config = ideClient.config
|
||||
const ideName =
|
||||
config.type === 'sse-ide' || config.type === 'ws-ide'
|
||||
? config.ideName
|
||||
: null
|
||||
if (ideClient.type === 'connected') {
|
||||
return { status: 'connected', ideName }
|
||||
}
|
||||
if (ideClient.type === 'pending') {
|
||||
return { status: 'pending', ideName }
|
||||
}
|
||||
return { status: 'disconnected', ideName }
|
||||
}, [mcpClients])
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { getUserMessageText } from '../utils/messages.js'
|
||||
|
||||
const EXTERNAL_COMMAND_PATTERNS = [
|
||||
/\bcurl\b/,
|
||||
/\bwget\b/,
|
||||
/\bssh\b/,
|
||||
/\bkubectl\b/,
|
||||
/\bsrun\b/,
|
||||
/\bdocker\b/,
|
||||
/\bbq\b/,
|
||||
/\bgsutil\b/,
|
||||
/\bgcloud\b/,
|
||||
/\baws\b/,
|
||||
/\bgit\s+push\b/,
|
||||
/\bgit\s+pull\b/,
|
||||
/\bgit\s+fetch\b/,
|
||||
/\bgh\s+(pr|issue)\b/,
|
||||
/\bnc\b/,
|
||||
/\bncat\b/,
|
||||
/\btelnet\b/,
|
||||
/\bftp\b/,
|
||||
]
|
||||
|
||||
const FRICTION_PATTERNS = [
|
||||
// "No," or "No!" at start — comma/exclamation implies correction tone
|
||||
// (avoids "No problem", "No thanks", "No I think we should...")
|
||||
/^no[,!]\s/i,
|
||||
// Direct corrections about Claude's output
|
||||
/\bthat'?s (wrong|incorrect|not (what|right|correct))\b/i,
|
||||
/\bnot what I (asked|wanted|meant|said)\b/i,
|
||||
// Referencing prior instructions Claude missed
|
||||
/\bI (said|asked|wanted|told you|already said)\b/i,
|
||||
// Questioning Claude's actions
|
||||
/\bwhy did you\b/i,
|
||||
/\byou should(n'?t| not)? have\b/i,
|
||||
/\byou were supposed to\b/i,
|
||||
// Explicit retry/revert of Claude's work
|
||||
/\btry again\b/i,
|
||||
/\b(undo|revert) (that|this|it|what you)\b/i,
|
||||
]
|
||||
|
||||
export function isSessionContainerCompatible(messages: Message[]): boolean {
|
||||
for (const msg of messages) {
|
||||
if (msg.type !== 'assistant') {
|
||||
continue
|
||||
}
|
||||
const content = msg.message.content
|
||||
if (!Array.isArray(content)) {
|
||||
continue
|
||||
}
|
||||
for (const block of content) {
|
||||
if (block.type !== 'tool_use' || !('name' in block)) {
|
||||
continue
|
||||
}
|
||||
const toolName = block.name as string
|
||||
if (toolName.startsWith('mcp__')) {
|
||||
return false
|
||||
}
|
||||
if (toolName === BASH_TOOL_NAME) {
|
||||
const input = (block as { input?: Record<string, unknown> }).input
|
||||
const command = (input?.command as string) || ''
|
||||
if (EXTERNAL_COMMAND_PATTERNS.some(p => p.test(command))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function hasFrictionSignal(messages: Message[]): boolean {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i]!
|
||||
if (msg.type !== 'user') {
|
||||
continue
|
||||
}
|
||||
const text = getUserMessageText(msg)
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
return FRICTION_PATTERNS.some(p => p.test(text))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const MIN_SUBMIT_COUNT = 3
|
||||
const COOLDOWN_MS = 30 * 60 * 1000
|
||||
|
||||
export function useIssueFlagBanner(
|
||||
messages: Message[],
|
||||
submitCount: number,
|
||||
): boolean {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const lastTriggeredAtRef = useRef(0)
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const activeForSubmitRef = useRef(-1)
|
||||
|
||||
// Memoize the O(messages) scans. This hook runs on every REPL render
|
||||
// (including every keystroke), but messages is stable during typing.
|
||||
// isSessionContainerCompatible walks all messages + regex-tests each
|
||||
// bash command — by far the heaviest work here.
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
|
||||
const shouldTrigger = useMemo(
|
||||
() => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
|
||||
[messages],
|
||||
)
|
||||
|
||||
// Keep showing the banner until the user submits another message
|
||||
if (activeForSubmitRef.current === submitCount) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Date.now() - lastTriggeredAtRef.current < COOLDOWN_MS) {
|
||||
return false
|
||||
}
|
||||
if (submitCount < MIN_SUBMIT_COUNT) {
|
||||
return false
|
||||
}
|
||||
if (!shouldTrigger) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastTriggeredAtRef.current = Date.now()
|
||||
activeForSubmitRef.current = submitCount
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
type TerminalSize,
|
||||
TerminalSizeContext,
|
||||
} from 'src/ink/components/TerminalSizeContext.js'
|
||||
|
||||
export function useTerminalSize(): TerminalSize {
|
||||
const size = useContext(TerminalSizeContext)
|
||||
|
||||
if (!size) {
|
||||
throw new Error('useTerminalSize must be used within an Ink App component')
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { major, minor, patch } from 'semver'
|
||||
|
||||
export function getSemverPart(version: string): string {
|
||||
return `${major(version, { loose: true })}.${minor(version, { loose: true })}.${patch(version, { loose: true })}`
|
||||
}
|
||||
|
||||
export function shouldShowUpdateNotification(
|
||||
updatedVersion: string,
|
||||
lastNotifiedSemver: string | null,
|
||||
): boolean {
|
||||
const updatedSemver = getSemverPart(updatedVersion)
|
||||
return updatedSemver !== lastNotifiedSemver
|
||||
}
|
||||
|
||||
export function useUpdateNotification(
|
||||
updatedVersion: string | null | undefined,
|
||||
initialVersion: string = MACRO.VERSION,
|
||||
): string | null {
|
||||
const [lastNotifiedSemver, setLastNotifiedSemver] = useState<string | null>(
|
||||
() => getSemverPart(initialVersion),
|
||||
)
|
||||
|
||||
if (!updatedVersion) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedSemver = getSemverPart(updatedVersion)
|
||||
if (updatedSemver !== lastNotifiedSemver) {
|
||||
setLastNotifiedSemver(updatedSemver)
|
||||
return updatedSemver
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useAppState } from '../state/AppState.js'
|
||||
import {
|
||||
hasVoiceAuth,
|
||||
isVoiceGrowthBookEnabled,
|
||||
} from '../voice/voiceModeEnabled.js'
|
||||
|
||||
/**
|
||||
* Combines user intent (settings.voiceEnabled) with auth + GB kill-switch.
|
||||
* Only the auth half is memoized on authVersion — it's the expensive one
|
||||
* (cold getClaudeAIOAuthTokens memoize → sync `security` spawn, ~60ms/call,
|
||||
* ~180ms total in profile v5 when token refresh cleared the cache mid-session).
|
||||
* GB is a cheap cached-map lookup and stays outside the memo so a mid-session
|
||||
* kill-switch flip still takes effect on the next render.
|
||||
*
|
||||
* authVersion bumps on /login only. Background token refresh leaves it alone
|
||||
* (user is still authed), so the auth memo stays correct without re-eval.
|
||||
*/
|
||||
export function useVoiceEnabled(): boolean {
|
||||
const userIntent = useAppState(s => s.settings.voiceEnabled === true)
|
||||
const authVersion = useAppState(s => s.authVersion)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const authed = useMemo(hasVoiceAuth, [authVersion])
|
||||
return userIntent && authed && isVoiceGrowthBookEnabled()
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Cross-platform terminal clearing with scrollback support.
|
||||
* Detects modern terminals that support ESC[3J for clearing scrollback.
|
||||
*/
|
||||
|
||||
import {
|
||||
CURSOR_HOME,
|
||||
csi,
|
||||
ERASE_SCREEN,
|
||||
ERASE_SCROLLBACK,
|
||||
} from './termio/csi.js'
|
||||
|
||||
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
|
||||
const CURSOR_HOME_WINDOWS = csi(0, 'f')
|
||||
|
||||
function isWindowsTerminal(): boolean {
|
||||
return process.platform === 'win32' && !!process.env.WT_SESSION
|
||||
}
|
||||
|
||||
function isMintty(): boolean {
|
||||
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
|
||||
if (process.env.TERM_PROGRAM === 'mintty') {
|
||||
return true
|
||||
}
|
||||
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
|
||||
if (process.platform === 'win32' && process.env.MSYSTEM) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isModernWindowsTerminal(): boolean {
|
||||
// Windows Terminal sets WT_SESSION environment variable
|
||||
if (isWindowsTerminal()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// VS Code integrated terminal on Windows with ConPTY support
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
process.env.TERM_PROGRAM === 'vscode' &&
|
||||
process.env.TERM_PROGRAM_VERSION
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
|
||||
if (isMintty()) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ANSI escape sequence to clear the terminal including scrollback.
|
||||
* Automatically detects terminal capabilities.
|
||||
*/
|
||||
export function getClearTerminalSequence(): string {
|
||||
if (process.platform === 'win32') {
|
||||
if (isModernWindowsTerminal()) {
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
} else {
|
||||
// Legacy Windows console - can't clear scrollback
|
||||
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
|
||||
}
|
||||
}
|
||||
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the terminal screen. On supported terminals, also clears scrollback.
|
||||
*/
|
||||
export const clearTerminal = getClearTerminalSequence()
|
||||