diff --git a/docs/FEATURE_PROJECT_MANAGER.md b/docs/FEATURE_PROJECT_MANAGER.md new file mode 100644 index 0000000..3174f4d --- /dev/null +++ b/docs/FEATURE_PROJECT_MANAGER.md @@ -0,0 +1,242 @@ +# Feature: Project Manager 项目工作台 (v0.6) + +> **状态**: 提案 / Draft(探索完成,待评审后进入 PLAN) +> **目标**: 首页除了"会话选择器"外,新增一个 **项目面板**:展示主机上的所有项目(git 仓库),点一下某个项目 → 应用自动 spawn 一个 **Claude Code session**(cwd = 该仓库目录、自动运行 `claude`),并把 **tab 标签标注为 repo 名**。 +> **关键结论**: 终端机制几乎**零改动**——按目录 spawn (`attach.cwd`)、自动输入命令 (`initialInput`)、自定义标签 (`customTitle`) 全部已存在。新代码只有两块:**git 仓库发现 + `GET /projects` 端点** 和 **Projects 面板 UI**。 + +--- + +## 1. 用户故事 (User Stories) + +- **US1**(核心):作为开发者,我打开 web-terminal 首页,看到我所有项目的卡片墙;点 `web-terminal` 卡片,立刻开出一个新 tab,标签写着 `web-terminal`,里面已经 `cd` 到该目录并启动了 `claude`,我直接开始派活。 +- **US2**:每个项目卡片显示它的**当前分支**、是否有未提交改动 (dirty)、以及**是否已有正在运行的 session**(避免重复开)。 +- **US3**:如果某项目已经有一个跑着的 session,点卡片**直接进入那个 session**(而不是再开一个)。 +- **US4**:我能**搜索/过滤**项目(项目多时),把常用项目**置顶/收藏**。 +- **US5**:我能在卡片上选择"**新开** Claude session"还是"**只开 shell**(不自动跑 claude)",或"**resume** 该项目最近一次 Claude 会话"。 + +--- + +## 2. 竞品参考 (Prior Art) + +调研了主流的"项目选择器 → 启动 agent 会话"产品,提炼可借鉴点: + +| 产品 | 项目发现方式 | 卡片展示的元数据 | 启动交互 | 可借鉴 UX | +|---|---|---|---|---| +| **CloudCLI / claudecodeui** (siteboon) | 自动扫描 `~/.claude/projects/` 配置目录 | 项目名、路径、历史 session 列表 | 点项目 → 列出其 sessions → 选一个 resume / 新建 | 侧边栏树形项目→会话二级结构;与本地 Claude 配置同步 | +| **官方 Claude Code** | `~/.claude/projects/` 按 cwd 路径 keyed | cwd、历史会话 | `claude` 在 cwd 启动;`claude --resume ` | "项目 = 工作目录"这一极简心智模型 | +| **Conductor** (Melty Labs, Mac) | 每个 workspace = 一个 **git worktree** | 看板:每个 agent 在干嘛 / 卡在哪 | 把 ticket 拉进 workspace 当任务描述 | 看板式 dashboard + "拉一个 ticket 进来就开干" | +| **Crystal / Nimbalyst** (stravu) | 多个并行 session 跑在 git worktree | 并行会话、方案对比 | 一个项目开多个并行 session 比较产出 | 同一项目并行多 session、对比方案 | +| **AgentsRoom** | 手机端远程 | 终端输出、待回答提示 | 手机监控 + 回答 prompt | 移动端"监控 + 审批"闭环(本项目 v0.3 已有类似) | +| **tmuxinator / sesh** | 扫描配置/目录列出 project | 项目名 | 一键 attach "每项目一会话" | "每项目一会话"的命名与复用 | + +**结论 / 我们要偷的点:** +1. **项目 = 工作目录**(官方心智模型)——发现逻辑围绕"目录是不是 git 仓库"。 +2. **二级结构**(CloudCLI):项目 → 该项目下的历史/活动 session,点项目可"新建"或"进入已有"。 +3. **看板式状态**(Conductor):卡片直接显示该项目有没有 running session、在 working/waiting/idle 哪个状态(本项目 v0.3 hook 状态可复用)。 +4. **git 元数据**(分支 + dirty)让卡片"活"起来。 +5. 本项目的**差异化护城河**:自托管、不经云、多设备镜像同看、缩略图会话墙——Project 面板应与这些无缝融合(项目卡片可直接复用实时缩略图)。 + +--- + +## 3. 功能需求 (Functional Requirements) + +| ID | 需求 | 优先级 | +|----|------|--------| +| FR1 | 提供 `GET /projects`,返回主机上发现的项目列表(名称、绝对路径、是否 git、分支、dirty、最近活跃时间) | P0 | +| FR2 | 项目发现:扫描 1~N 个**配置的根目录**(默认 `~`,可 env 覆盖)下的 git 仓库;并合并 `~/.claude/projects` 里出现过的 cwd(最近用过的项目) | P0 | +| FR3 | 首页提供 **Projects 视图**(与现有 Sessions 选择器并列/可切换),渲染项目卡片网格 | P0 | +| FR4 | 点项目卡片**主操作** → 新开 tab:cwd=仓库路径、自动运行 `claude`、tab 标签=repo 名 | P0 | +| FR5 | **一个项目可对应多个 session**(1:N)。卡片展示该项目下**所有运行中的 session**(每个含名称/Claude 状态/缩略图),可分别进入;同时永远提供"+ 新开"。详见 §4.6 | P0 | +| FR6 | 卡片显示 **当前分支** + **dirty 标记** | P1 | +| FR7 | 项目搜索/过滤框;收藏/置顶(localStorage 持久化) | P1 | +| FR8 | 「+ 新开」菜单:新开 `claude` / 只开 shell / `claude --resume <最近会话>` | P2 | +| FR9 | 性能:发现逻辑有**深度上限**、跳过 `node_modules`/`.git` 内部、结果缓存,避免扫盘卡顿 | P0 | + +--- + +## 4. 设计与架构 (Design) + +### 4.1 数据流 + +``` +首页 Projects 视图 + │ GET /projects + ▼ +src/http/projects.ts ──扫描配置根目录找 .git──► ProjectInfo[] + │ ──合并 ~/.claude/projects 的 cwd──► + │ ──对每个 repo 读 branch / dirty──► + ▼ +卡片网格 (public/projects.ts) + │ 点卡片 + ▼ +TabApp.openProject(repoPath, repoName) + │ = addEntry(null, repoName, repoPath, 'claude') ← 全部已存在的能力 + ▼ +TerminalSession: attach{cwd: repoPath} → spawn PTY(cwd) → 700ms 后自动输入 "claude\r" + │ + ▼ +tab 标签 = customTitle(repoName)(最高优先级,shell 之后发的 OSC 标题不会覆盖) +``` + +**核心洞察**:从"点项目"到"开出带名字、跑着 claude 的 session"这条链路,**终端侧零改动**——直接调 `TabApp.addEntry(null, repoName, repoPath, 'claude')` 即可(见集成点表)。 + +### 4.2 既有集成点(无需改动,直接复用) + +| 能力 | 现状 | 钩子位置 | +|------|------|----------| +| 按目录 spawn PTY | ✅ 已有 (M6) | `attach.cwd` → `manager.handleAttach(…, cwd)` → `createSession(…, cwd)` → `spawn({cwd})` (`src/session/session.ts:93`) | +| 自动运行 `claude` | ✅ 已有 (O2, 客户端驱动) | `TerminalSessionOpts.initialInput`,`attached` 后 700ms 自动输入 (`public/terminal-session.ts:253-261`) | +| 标签=repo 名 | ✅ 已有 | `addEntry(null, repoName, repoPath, 'claude')`;`customTitle` 优先级最高 (`public/tabs.ts:107,143`) | +| 启动器视图骨架 | ✅ 可复用 | `mountLauncher` / `.mg-grid` / `makePreviewCard` (`public/launcher.ts`, `public/preview-grid.ts`) | +| 历史 cwd 扫描模板 | ✅ 可复用 | `listSessions()` 扫 `~/.claude/projects/*.jsonl`,提取 cwd (`src/http/history.ts:80`) | + +### 4.3 新增后端:`src/http/projects.ts`(模仿 `history.ts`) + +```ts +// 在 src/types.ts 增加共享契约(协调点,不要本地重声明) +export interface ProjectInfo { + name: string; // repo 目录名 + path: string; // 绝对路径 + isGit: boolean; + branch?: string; // 当前分支(git 仓库才有) + dirty?: boolean; // 有无未提交改动 + lastActiveMs?: number; // 来自 ~/.claude/projects 的最近 mtime,用于排序 + sessions: ProjectSessionRef[]; // 该项目下当前运行中的 session(1:N,可能为空) +} + +// 一个项目下的某个运行中 session 的引用(由 /projects 把 live-sessions 按 cwd 归并而来) +export interface ProjectSessionRef { + id: string; // sessionId + title?: string; // tab 标题 / 自动名(如 'claude', 'shell', 'test') + claudeStatus?: 'working' | 'waiting' | 'idle'; // 复用 v0.3 hook 状态 + startedMs: number; + clients: number; // 当前挂着几个 WS 客户端(多设备镜像) +} +``` + +```ts +// src/http/projects.ts (纯异步、best-effort、parse 逻辑可单测) +export async function listProjects(cfg): Promise +// 1. roots = cfg.projectRoots(默认 [os.homedir()]) +// 2. 广度优先扫描,遇到 .git 即记为 repo 并停止下钻该子树 +// - 深度上限 cfg.projectScanDepth(默认 4) +// - 跳过 node_modules / .git / 隐藏目录 / 符号链接环 +// 3. 合并 history.ts 收集到的 cwd(最近用过但不在扫描根下的项目) +// 4. 对每个 repo:读分支 = 解析 .git/HEAD(轻量,免 spawn git); +// dirty = `git status --porcelain` 截断输出(可选、可惰性/并发限流) +// 5. 去重(by path)、按 lastActiveMs 倒序、限量返回 +``` + +> **分支读取**:优先 `读取 .git/HEAD`(`ref: refs/heads/`)避免为每个 repo spawn git,扫描快。dirty 状态较贵(要 `git status`),可**惰性**(卡片展开/hover 时再查)或**并发限流**(一次最多 N 个)以免大量 repo 卡顿。 + +### 4.4 新增端点:`GET /projects` + +只读发现端点,**与 `/sessions`、`/live-sessions` 同类**——不需要 Origin/CSRF 守卫(那是给状态变更路由的)。放在 `src/server.ts:151` 附近: + +```ts +app.get('/projects', async (_req, res) => res.json(await listProjects(cfg))); +``` + +### 4.5 前端:`public/projects.ts` + `TabApp.openProject` + +- `mountProjects(host, { onOpenProject, onEnterSession })`,与 `mountLauncher` 并列挂载(`tabs.ts:59` 旁)。 +- 首页加 **Sessions ↔ Projects 切换**(两个 tab/段控),复用 `.mg-grid` 与 `el()` 卡片工具。 +- 项目卡片:repo 名 + 分支 chip + dirty 圆点 + **session 计数徽标 + 该项目所有 running session 列表**(见 §4.6)。 +- `openProject`(新开)与 `openSession`(进入既有,已存在于 `tabs.ts:129`): + +```ts +// public/tabs.ts 新增(与 newTabForResume 几乎同构) +openProject(repoPath: string, repoName: string, cmd = 'claude') { + // 同名多开时加序号后缀,避免标签全叫 'web-terminal' + const n = this.countOpenWithTitlePrefix(repoName); + const label = n === 0 ? repoName : `${repoName} #${n + 1}`; + this.addEntry(null, label, repoPath, cmd); // customTitle, cwd, initialInput + this.rebuild(); // + activate 新 tab +} +``` + +--- + +### 4.6 一个项目 ↔ 多个 session(1:N,核心修正) + +一个项目常会同时存在多个 session:**一个 `claude` + 一个跑测试/`git` 的 shell**、或**并行开几个 claude 比方案**(Conductor/Crystal 模式)、或同一会话被**多设备镜像**。因此卡片不能假设"0 或 1 个会话"。 + +**归并逻辑(后端 `listProjects`)**:把 `manager.list()`(`GET /live-sessions`,每个含 `cwd`、`clients`,见 `session.ts:116`)按 **cwd 前缀归属到项目**,填进 `ProjectInfo.sessions[]`;Claude 状态来自 v0.3 hook 侧信道(按 sessionId)。 + +> **匹配口径**:以 session 的**spawn cwd** 为锚(稳定,不随用户 `cd` 漂移)。`cwd === project.path` 或 `cwd` 在 `project.path` 之下都算该项目。**git worktree**(同 repo、不同路径)v0.6 先按各自路径当**独立项目**呈现;"按 repo 把多个 worktree 收拢成一组"列入未来扩展(§9)。 + +**卡片交互(按 session 数自适应)**: + +| 该项目 running session 数 | 卡片主体 | 主操作 | +|---|---|---| +| **0** | "+ start claude here" 占位 | **Open ▸ claude**(`openProject`) | +| **1** | 1 个 session 行(状态点 + 名称 + 缩略图) | 点行 = 进入;底部 **+ New** | +| **N** | N 个 session 行(各带 ⚙/⏳/✓ 状态、镜像端数、可单独进入/Kill) | 每行点击进入对应 session;底部 **+ New claude ▾** | + +- 每个 session 行:`● 状态点` + `标题`(claude / shell / `#2`…)+ `👥×2`(镜像设备数)+ 点击 → `onEnterSession(id)` = `TabApp.openSession(id)`(已有,会聚焦既有 tab 或挂载该 session,不重复开)。 +- 行尾 `✕` = Kill 该 session(复用 `DELETE /live-sessions/:id`,带 Origin 守卫)。 +- 「+ New」永远在:因为"已有会话"不代表"不想再开一个"。`▾` 展开 = 新 claude / 只开 shell / `claude --resume`。 +- **同名多 tab**:`openProject` 给第 2+ 个加 `#n` 后缀(如 `web-terminal #2`),避免标签无法区分。 + +--- + +## 5. 安全考量 (Security) + +- `GET /projects` 只读,符合现有威胁模型(工具已对 LAN 交出整个 shell,暴露目录名不额外升级风险)——与 `/sessions` 一致,**不加** Origin 守卫,但**也不能写**。 +- **路径越界**:发现的路径仅在服务端本地枚举;前端传回的 `cwd` 走既有 `attach.cwd` 校验(必须绝对路径,`protocol.ts:142`),不引入新攻击面。 +- **扫描成本即 DoS 面**:深度上限 + 跳过大目录 + 结果缓存(如 10s TTL),避免被频繁刷 `/projects` 拖垮磁盘。 +- dirty 检查用 `git status` 会 spawn 子进程——**限流 + 超时**,防止 repo 多时 fork 爆炸。 + +--- + +## 6. 配置 (新增 env,沿用"无硬编码"原则) + +| Env | 默认 | 说明 | +|-----|------|------| +| `PROJECT_ROOTS` | `~`(home 目录) | 逗号分隔的扫描根目录 | +| `PROJECT_SCAN_DEPTH` | `4` | 扫描最大深度 | +| `PROJECT_SCAN_TTL` | `10000` (ms) | `/projects` 结果缓存时长 | +| `PROJECT_DIRTY_CHECK` | `1` | 是否计算 dirty(0=关,省 git 调用) | + +--- + +## 7. 任务拆解 (v0.6 — 按 PLAN/Owns 模型) + +> 沿用 v0.4/v0.5 的做法:作为 feature 条目并入,定义 disjoint `Owns:` 便于并行派给 `module-builder`。 + +| 任务 | Owns(独占写) | 依赖 | 说明 | +|------|----------------|------|------| +| **P1 类型契约** | `src/types.ts`(加 `ProjectInfo`,改 `Config` 加 project* 字段) | — | 协调点,先落地,解锁并行 | +| **P2 后端发现** | `src/http/projects.ts` + `test/projects.test.ts` | P1 | git 扫描 + HEAD/dirty + 合并 history cwd + 缓存(TDD,parse 纯函数单测) | +| **P3 配置** | `src/config.ts`(加 `projectRoots/scanDepth/...`) | P1 | env 解析 + 默认值 | +| **P4 端点接线** | `src/server.ts`(加 `GET /projects` 一行 + import) | P2,P3 | 与 `/sessions` 同位 | +| **P5 前端面板** | `public/projects.ts` + 样式 | P1 | 卡片网格、搜索、收藏、切换视图 | +| **P6 Tab 接线** | `public/tabs.ts`(`openProject` + 挂载 `mountProjects` + running-session 匹配) | P5 | 复用 `addEntry`/`openSession` | + +> P2/P3/P5 同波次、文件 disjoint,可并行;P4/P6 收尾接线。建议 builder 用 `isolation: worktree`。 + +--- + +## 8. 验收标准 (Acceptance) + +- **A1**:`GET /projects` 在含多个 git 仓库的目录树下返回正确列表(名称/路径/分支/dirty),扫描有深度上限、跳过 `node_modules`,10s 内缓存命中。 +- **A2**:首页能切到 Projects 视图,渲染卡片网格;分支与 dirty 显示正确。 +- **A3**:点一个**无运行会话**的项目 → 新 tab 标签=repo 名、cwd 正确、约 0.7s 后自动出现 `claude` 启动界面。 +- **A4**:点一个**已有运行会话**的项目 → 进入既有 session,不重复开。 +- **A5**:搜索过滤、收藏置顶生效并持久化。 +- **A6**:后端测试覆盖发现/解析逻辑(≥80%,纯函数单测 + 临时目录集成)。 + +--- + +## 9. 未来扩展 / 暂不做 (Out of Scope for v0.6) + +- **git worktree 看板**(Conductor/Crystal 式:一个项目并行多 worktree 多 agent)——强大但属另一个大特性,v0.6 先做"一项目一会话"。 +- 项目内**文件树/编辑器/Git Explorer**(CloudCLI 有)——与本项目 byte-shuttle 哲学冲突,明确不做。 +- 从 ticket/issue **拉任务进项目**(Conductor 式)——后续可作为 v0.7 与 hook 系统结合。 +- 多 agent 中立(Cursor/Codex/Gemini)——目前 hooks 与 `claude` 命令是 Claude 专用;如需中立化,`initialInput` 可改为可配置命令。 + +--- + +## 附:一句话总结 + +> 这是一个**"发现源 + 启动器视图"**特性。开 session 的终端机制(按目录 spawn、自动跑 claude、标签=repo 名)**今天就能做到**,只差**仓库发现(`GET /projects`)**和**项目面板 UI**两块新代码。 diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 58c33ee..f713a70 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,6 +24,20 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 +### 🚧 v0.6 Project Manager 项目工作台(进行中 — 2026-06-30 交接) + +- **设计/计划已就绪**: `docs/FEATURE_PROJECT_MANAGER.md`(含 1:N 多 session 设计)、`docs/THEME.md`(Amber 主题)、`docs/mockups/`(预览图,`final-amber.png` 为定稿视觉)。计划见会话内 `/ecc:plan` 输出(wave 分波 + Owns)。 +- **主题已 ship**: `public/style.css` 靛蓝 `#7c8cff` → **Amber 琥珀金 `#e3a64a`**;新增 `--on-accent`;暖灰底;`--green/--amber/--red` 状态色保留;keybar 残留靛蓝已清。 +- **地基已落(编译绿,未提交)**: + - ✅ **T-PM1 契约** `src/types.ts`: 新增 `ProjectInfo` / `ProjectSessionRef`(复用 `ClaudeStatus`) + Config 4 字段(`projectRoots/projectScanDepth/projectScanTtlMs/projectDirtyCheck`)。 + - ✅ **T-PM3 配置** `src/config.ts`: `parseBool` / `parseProjectRoots` helper + 解析 4 个 env(`PROJECT_ROOTS`/`PROJECT_SCAN_DEPTH`/`PROJECT_SCAN_TTL`/`PROJECT_DIRTY_CHECK`,默认 `[homeDir]`/4/10000/true)。`npm run typecheck` 通过。 +- **下一步(重启后从这里继续)**: + 1. **W1 并行 2 builder(worktree)**: **T-PM2** 后端发现 `src/http/projects.ts` + `test/projects.test.ts`(`buildProjects(cfg, liveSessions)`:扫 .git→分支/dirty→合并 ~/.claude/projects cwd→按 cwd 归并 sessions→缓存,镜像 `src/http/history.ts:80` 模式);**T-PM5** 前端面板 `public/projects.ts` + style 追加(`mountProjects`,1:N 卡片,复用 launcher/`el()`/Amber token)。 + 2. **W2 并行 2**: **T-PM4** `src/server.ts` 加 `GET /projects`(只读,无 Origin 守卫,喂 `manager.list()`);**T-PM6** `public/tabs.ts` `openProject`(同名加 `#n`,复用 `addEntry`)+ 挂 `mountProjects` + 首页 Sessions↔Projects 段控。 + 3. **W3**: `module-reviewer` 验收 A1–A6(report-only)。 + - 关键接口约定: `buildProjects(cfg, liveSessions): Promise`(注入 liveSessions 便于纯测)。 +- **注意**: 当前工作树有未提交改动(types.ts/config.ts/style.css/docs)。建议重启后先 `git status` 核对,按规范建 `v0.6-projects` 分支再继续。 + - **当前阶段**: **v0.3 全部完成**(H1–H4 + M3/M6/M7 已并入 main;**O2 历史浏览** 在分支 `o2-history`,待合并)。 - ✅ Step1 前端快赢 · ✅ H2/H4 状态感知 · ✅ H3 远程批准 · ✅ H1 tmux 保活 · ✅ M3/M7/M6 · ✅ **O2 历史会话浏览/resume** · ✅ tech-debt 清理(@ts-ignore 去掉)· ✅ UI:终端不再打印 Connecting/Connected(靠标签点)。 - ✅ **现代化 UI 主题**(design-token 调色板/靛蓝强调色/圆角芯片标签+键栏/毛玻璃弹层)· ✅ **键栏功能字幕**(每键下方标作用)+ 新增 ^R/^L/^D(共 17 键)· ✅ **⌨ 快捷键速查弹层**(`public/shortcuts.ts`)。 diff --git a/docs/THEME.md b/docs/THEME.md new file mode 100644 index 0000000..0b79df7 --- /dev/null +++ b/docs/THEME.md @@ -0,0 +1,50 @@ +# Theme — Amber 琥珀金(设计 token 源) + +> 全站视觉的**单一事实来源**。强调色全部来自 `--accent / --accent-2 / --accent-soft`, +> 改这几个 token 即可整体换肤。定义在 `public/style.css` 的 `:root`。 +> 选型经过 10 套候选对比(见 `docs/mockups/palette-compare*.png`),最终定为 **Amber 琥珀金**: +> 暖中性底 + 金色强调,暗色护眼、长时间看不累,且明确避开蓝/紫/绿。 + +## 调色板 (Design Tokens) + +| Token | 值 | 用途 | +|-------|-----|------| +| `--bg` | `#100f0d` | 终端 / 最深表面(暖中性,非冷蓝灰) | +| `--surface-1` | `#181613` | 标签栏、键栏 | +| `--surface-2` | `#1f1c17` | 标签、按钮、卡片 | +| `--surface-3` | `#2a2620` | hover / active | +| `--border` | `rgba(255,255,255,.07)` | 普通描边 | +| `--border-strong` | `rgba(255,255,255,.14)` | 强描边 | +| `--text` | `#ece9e3` | 主文字(暖白) | +| `--text-dim` | `#a8a299` | 次要文字 | +| `--text-faint` | `#6f6a61` | 弱化文字 | +| **`--accent`** | **`#e3a64a`** | **强调色:金色**(按钮、链接、选中、聚焦环) | +| `--accent-2` | `#c9892f` | 深金(渐变 / hover) | +| `--accent-soft` | `rgba(227,166,74,.15)` | 强调色半透明(选中底、chip、聚焦光晕) | +| `--on-accent` | `#1a1305` | **金色底上的文字色**(深墨,保证对比度——金底配白字会糊) | + +## 状态色(语义独立,勿与强调色混用) + +连接状态点沿用既有语义,**不随主题强调色变**: + +| Token | 值 | 含义 | +|-------|-----|------| +| `--green` | `#46d07f` | 已连接 connected | +| `--amber` | `#f5b14c` | 连接中 connecting | +| `--red` | `#ff6b6b` | 断开 / 出错 disconnected / error | + +> Project 面板里的 session 状态语义(v0.6):`working = --accent 金` · `waiting = --amber 琥珀(闪)` · `idle = 中性灰 #8b8a86` · `dirty = 暖琥珀`。 + +## 接入约定(改主题只改这里) + +- 任何"强调"视觉 **只用 token**,不要写死颜色(`#7c8cff` 这类硬编码已清除)。 +- **金色当背景时,文字必须用 `var(--on-accent)`**(深墨),不要用 `#fff`。 + 当前 4 处 accent 背景按钮已统一改为 `--on-accent`:`.settings-opt.active` / `.hist-resume` 等。 +- 换肤 = 替换 `:root` 的 `--accent / --accent-2 / --accent-soft / --on-accent`(必要时连 `--bg/--surface-*` 一起)。 +- 历史:原 indigo 靛蓝 `#7c8cff` 已于本次替换为 Amber(git 可回溯)。 + +## 候选存档 + +10 套对比预览(暖底、无蓝/紫/绿):`docs/mockups/palette-compare.png`(Amber/Copper/Mono/Rose)、 +`docs/mockups/palette-compare-2.png`(Ruby/Sand/Brass/Apricot/Slate/Clay)。 +最终全面板预览:`docs/mockups/final-amber.png`。 diff --git a/docs/mockups/final-amber.png b/docs/mockups/final-amber.png new file mode 100644 index 0000000..e5901d0 Binary files /dev/null and b/docs/mockups/final-amber.png differ diff --git a/docs/mockups/final-clay.png b/docs/mockups/final-clay.png new file mode 100644 index 0000000..ca1715c Binary files /dev/null and b/docs/mockups/final-clay.png differ diff --git a/docs/mockups/final-sand.png b/docs/mockups/final-sand.png new file mode 100644 index 0000000..e5901d0 Binary files /dev/null and b/docs/mockups/final-sand.png differ diff --git a/docs/mockups/palette-compare-2.html b/docs/mockups/palette-compare-2.html new file mode 100644 index 0000000..031360c --- /dev/null +++ b/docs/mockups/palette-compare-2.html @@ -0,0 +1,95 @@ + + + +Palette compare 2 + + +

再来 6 套强调色(暖灰底不变,无蓝/紫/绿)

+

working=强调色 · waiting=琥珀(闪) · idle=中性灰 · dirty=暖琥珀

+
+ + diff --git a/docs/mockups/palette-compare-2.png b/docs/mockups/palette-compare-2.png new file mode 100644 index 0000000..0291b90 Binary files /dev/null and b/docs/mockups/palette-compare-2.png differ diff --git a/docs/mockups/palette-compare.html b/docs/mockups/palette-compare.html new file mode 100644 index 0000000..4949d61 --- /dev/null +++ b/docs/mockups/palette-compare.html @@ -0,0 +1,132 @@ + + + +Palette compare + + +

选个强调色 — 同一张卡片,4 套配色(暖灰底不变,无蓝/紫/绿)

+

working=强调色 · waiting=琥珀(闪) · idle=中性灰 · dirty=暖琥珀

+
+ + +
+
琥珀金 Amber#e3a64a
+
+
web-terminal3
+
⎇ maindirty
+
~/Documents/git/web-terminal
+
claude⚙ working
+
claude #2⏳ waiting
+
shell✓ idle
+
+ New claude
+
+
Open ▸ claude
+
+ + +
+
赤陶橙 Copper#e8743b
+
+
web-terminal3
+
⎇ maindirty
+
~/Documents/git/web-terminal
+
claude⚙ working
+
claude #2⏳ waiting
+
shell✓ idle
+
+ New claude
+
+
Open ▸ claude
+
+ + +
+
暖色单色 Mono#ece9e3
+
+
web-terminal3
+
⎇ maindirty
+
~/Documents/git/web-terminal
+
claude⚙ working
+
claude #2⏳ waiting
+
shell✓ idle
+
+ New claude
+
+
Open ▸ claude
+
+ + +
+
玫瑰珊瑚 Rose#ef6f73
+
+
web-terminal3
+
⎇ maindirty
+
~/Documents/git/web-terminal
+
claude⚙ working
+
claude #2⏳ waiting
+
shell✓ idle
+
+ New claude
+
+
Open ▸ claude
+
+ +
+ diff --git a/docs/mockups/palette-compare.png b/docs/mockups/palette-compare.png new file mode 100644 index 0000000..85419c1 Binary files /dev/null and b/docs/mockups/palette-compare.png differ diff --git a/docs/mockups/project-manager-final.html b/docs/mockups/project-manager-final.html new file mode 100644 index 0000000..8e63549 --- /dev/null +++ b/docs/mockups/project-manager-final.html @@ -0,0 +1,175 @@ + + + + + +Project Manager — final + + + +
+
web-terminal
+
+
+
+
+
+
+

Projects

Amber + + +
+
+
+
web-terminal3 sessions
+
⎇ maindirty2h ago
+
~/Documents/git/web-terminal
+
+
claude⚙ working👥 2
+
claude #2⏳ waiting approval
+
shell · npm test✓ idle
+
+
+
+
+
colacoder-api1 session
+
⎇ feat/auth5d ago
+
~/Documents/git/colacoder-api
+
claude✓ idle · 20m
+
+
+
+
dotfilesidle
+
⎇ main12d ago
+
~/.dotfiles
+
+ start claude here
+
+
+
+
scratchidle
+
not a git repo
+
~/scratch
+
+ start shell here
+
+
+
Add folder…
browse a path to track
+
+
UI mockup · 一项目可挂多 session(每行可单独进入 / ✕Kill)· working=强调色 waiting=琥珀 idle=中性灰
+
+ + + diff --git a/docs/mockups/project-manager.html b/docs/mockups/project-manager.html new file mode 100644 index 0000000..51b5ceb --- /dev/null +++ b/docs/mockups/project-manager.html @@ -0,0 +1,270 @@ + + + + + +Project Manager — UI Mockup + + + +
+
web-terminal
+
+ + +
+
+
+
+ +
+
+

Projects

+ 5 found · 2 running · 4 sessions + + +
+ +
+ + +
+
+ + web-terminal + 3 sessions +
+
+ ⎇ main + dirty + 2h ago +
+
~/Documents/git/web-terminal
+
+
+ + claude + ⚙ working + 👥 2 + +
+
+ + claude #2 + ⏳ waiting approval + +
+
+ + shell · npm test + ✓ idle + +
+
+
+ + +
+
+ + +
+
+ colacoder-api + 1 session +
+
+ ⎇ feat/auth + 5d ago +
+
~/Documents/git/colacoder-api
+
+
+ + claude + ✓ idle · 20m + +
+
+
+ + +
+
+ + +
+
+ + dotfiles + idle +
+
+ ⎇ main + 12d ago +
+
~/.dotfiles
+
+ start claude here
+
+ + +
+
+ + +
+
+ scratch + idle +
+
+ not a git repo +
+
~/scratch
+
+ start shell here
+
+ + +
+
+ + +
+
+
Add folder…
+
browse a path to track
+
+ +
+ +
UI mockup · 暖灰底 + 翡翠绿强调色(无蓝/紫)· 状态色:working 绿 / waiting 琥珀 / idle 中性灰 · 一项目可挂多 session,每行可单独进入或 ✕Kill。
+
+ + diff --git a/docs/mockups/project-manager.png b/docs/mockups/project-manager.png new file mode 100644 index 0000000..51d23ab Binary files /dev/null and b/docs/mockups/project-manager.png differ diff --git a/public/style.css b/public/style.css index 47b63e4..ac89b5a 100644 --- a/public/style.css +++ b/public/style.css @@ -1,22 +1,23 @@ /* Web Terminal — modern dark UI */ :root { - /* palette (design tokens) */ - --bg: #0e0f13; /* terminal / deepest surface */ - --surface-1: #15161c; /* tab bar, key bar */ - --surface-2: #1c1e26; /* tabs, buttons, cards */ - --surface-3: #252834; /* hover / active */ + /* palette (design tokens) — Amber 琥珀金 theme (warm-neutral base + gold accent) */ + --bg: #100f0d; /* terminal / deepest surface — warm near-neutral */ + --surface-1: #181613; /* tab bar, key bar */ + --surface-2: #1f1c17; /* tabs, buttons, cards */ + --surface-3: #2a2620; /* hover / active */ --border: rgba(255, 255, 255, 0.07); - --border-strong: rgba(255, 255, 255, 0.13); - --text: #e7e8ec; - --text-dim: #9aa0ad; - --text-faint: #6b7080; - --accent: #7c8cff; /* indigo */ - --accent-2: #9d7cff; - --accent-soft: rgba(124, 140, 255, 0.16); - --green: #46d07f; - --amber: #f5b14c; - --red: #ff6b6b; + --border-strong: rgba(255, 255, 255, 0.14); + --text: #ece9e3; + --text-dim: #a8a299; + --text-faint: #6f6a61; + --accent: #e3a64a; /* amber gold */ + --accent-2: #c9892f; /* deeper gold (gradients / hover) */ + --accent-soft: rgba(227, 166, 74, 0.15); + --on-accent: #1a1305; /* text/icon color on a gold accent background (contrast) */ + --green: #46d07f; /* connection-status: connected */ + --amber: #f5b14c; /* connection-status: connecting */ + --red: #ff6b6b; /* connection-status: disconnected / error */ --shadow: 0 10px 40px rgba(0, 0, 0, 0.55); --radius: 9px; --radius-lg: 14px; @@ -306,16 +307,16 @@ body { } #keybar button.keybar-btn-primary { background: var(--accent-soft); - color: #cdd4ff; - border-color: rgba(124, 140, 255, 0.4); + color: #f0d49a; + border-color: rgba(227, 166, 74, 0.4); font-weight: 600; min-width: 54px; } #keybar button.keybar-btn-primary:hover { - background: rgba(124, 140, 255, 0.26); + background: rgba(227, 166, 74, 0.26); } #keybar button.keybar-btn-primary .kb-cap { - color: rgba(205, 212, 255, 0.7); + color: rgba(240, 212, 154, 0.7); } /* ── Shared: overlays, cards, inputs, buttons ────────────────────── */ @@ -467,7 +468,7 @@ body { } .settings-opt.active { background: var(--accent); - color: #fff; + color: var(--on-accent); border-color: var(--accent); } .settings-size { @@ -612,7 +613,7 @@ body { .hist-resume { flex: none; background: var(--accent); - color: #fff; + color: var(--on-accent); border: none; border-radius: 8px; padding: 7px 15px; @@ -692,7 +693,7 @@ body { } .launcher-new { background: var(--accent); - color: #fff; + color: var(--on-accent); border: none; border-radius: 8px; padding: 9px 16px; @@ -844,7 +845,7 @@ body { } .mg-open { background: var(--accent); - color: #fff; + color: var(--on-accent); border-radius: 8px; padding: 7px 13px; text-decoration: none; diff --git a/src/config.ts b/src/config.ts index 59cba55..060738b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,8 @@ const DEFAULT_MAX_MSGS_PER_SEC = 2000 const DEFAULT_PERM_TIMEOUT_MS = 5 * 60_000 // H3: hold a PermissionRequest for 5 min const DEFAULT_REAP_INTERVAL_MS = 60_000 // idle reaper sweeps every minute const DEFAULT_PREVIEW_BYTES = 24 * 1024 // manage-page preview: tail of scrollback +const DEFAULT_PROJECT_SCAN_DEPTH = 4 // v0.6: how deep to scan roots for .git +const DEFAULT_PROJECT_SCAN_TTL_MS = 10_000 // v0.6: /projects discovery cache TTL // ── helpers ─────────────────────────────────────────────────────────────────── @@ -50,6 +52,27 @@ function parseNonNegativeInt( return n } +/** Parse a boolean env value ('1'/'true'/'on' → true, '0'/'false'/'off' → false), else fallback. */ +function parseBool(raw: string | undefined, fallback: boolean): boolean { + const v = raw?.trim().toLowerCase() + if (v === undefined || v === '') return fallback + if (v === '1' || v === 'true' || v === 'on') return true + if (v === '0' || v === 'false' || v === 'off') return false + return fallback +} + +/** Parse PROJECT_ROOTS (comma-separated absolute paths); expand a leading '~' to homeDir. + * Empty / unset → [homeDir]. */ +function parseProjectRoots(raw: string | undefined, homeDir: string): readonly string[] { + if (raw === undefined || raw.trim() === '') return Object.freeze([homeDir]) + const roots = raw + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((s) => (s === '~' ? homeDir : s.startsWith('~/') ? homeDir + s.slice(1) : s)) + return Object.freeze(roots.length > 0 ? roots : [homeDir]) +} + function parsePort(raw: string | undefined): number { if (raw === undefined) return DEFAULT_PORT const n = Number(raw) @@ -195,6 +218,20 @@ export function loadConfig(env: EnvLike): Config { const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) + // v0.6 Project Manager — discovery config + const projectRoots = parseProjectRoots(env['PROJECT_ROOTS'], homeDir) + const projectScanDepth = parseNonNegativeInt( + env['PROJECT_SCAN_DEPTH'], + 'PROJECT_SCAN_DEPTH', + DEFAULT_PROJECT_SCAN_DEPTH, + ) + const projectScanTtlMs = parseNonNegativeInt( + env['PROJECT_SCAN_TTL'], + 'PROJECT_SCAN_TTL', + DEFAULT_PROJECT_SCAN_TTL_MS, + ) + const projectDirtyCheck = parseBool(env['PROJECT_DIRTY_CHECK'], true) + return Object.freeze({ port, bindHost, @@ -211,5 +248,9 @@ export function loadConfig(env: EnvLike): Config { previewBytes, useTmux, allowedOrigins, + projectRoots, + projectScanDepth, + projectScanTtlMs, + projectDirtyCheck, } satisfies Config) } diff --git a/src/types.ts b/src/types.ts index 3f75362..8b02ebe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,6 +34,11 @@ export interface Config { readonly previewBytes: number; // manage-page preview: bytes of scrollback tail rendered readonly useTmux: boolean; // H1: spawn the shell inside tmux so it survives a server restart readonly allowedOrigins: readonly string[]; // derived from NIC IPs, NOT bindHost (M1) + // v0.6 Project Manager — project discovery (impl: src/config.ts T-PM3) + readonly projectRoots: readonly string[]; // PROJECT_ROOTS, default [homeDir] + readonly projectScanDepth: number; // PROJECT_SCAN_DEPTH, default 4 + readonly projectScanTtlMs: number; // PROJECT_SCAN_TTL, default 10000 (discovery cache) + readonly projectDirtyCheck: boolean; // PROJECT_DIRTY_CHECK, default true (git status per repo) } /** process.env is structurally assignable to this; keeps the file free of `NodeJS.*`. */ @@ -192,6 +197,31 @@ export interface LiveSessionInfo { rows: number; } +/* ───────────────── project manager (v0.6, §4.3 FEATURE doc) ──────────────── */ + +/** One running session belonging to a project (live-sessions归并 by cwd). + * Mirrors LiveSessionInfo fields so buildProjects can map directly. */ +export interface ProjectSessionRef { + id: string; + title?: string; // tab title / derived label (e.g. 'claude', 'shell'); optional + status: ClaudeStatus; // reuse hook status (working|waiting|idle|unknown) + clientCount: number; // mirror devices currently attached + createdAt: number; + exited: boolean; +} + +/** A discovered project (git repo or recently-used cwd) for the Projects panel. + * impl: src/http/projects.ts buildProjects(cfg, liveSessions) — T-PM2. */ +export interface ProjectInfo { + name: string; // repo directory name + path: string; // absolute path + isGit: boolean; + branch?: string; // current branch (git repos only) + dirty?: boolean; // uncommitted changes (when projectDirtyCheck) + lastActiveMs?: number; // newest ~/.claude/projects mtime for this cwd; sort key + sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty) +} + export interface SessionManager { handleAttach( ws: WebSocketLike,