@nemus-cli/nemus 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/LICENSE +21 -0
- package/README.md +275 -0
- package/assets/README.md +53 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +66 -0
- package/assets/favicon-32.png +0 -0
- package/assets/favicon-64.png +0 -0
- package/assets/icon-256.png +0 -0
- package/assets/icon.svg +23 -0
- package/assets/logo-256.png +0 -0
- package/assets/logo-512.png +0 -0
- package/assets/logo-wordmark.svg +35 -0
- package/assets/logo.svg +32 -0
- package/bin/workspace.js +130 -0
- package/dist/cli/ai-prompt.js +421 -0
- package/dist/cli/dashboard/AgentList.js +61 -0
- package/dist/cli/dashboard/DashboardSidebar.js +280 -0
- package/dist/cli/dashboard/HelpFooter.js +73 -0
- package/dist/cli/dashboard/StatusBadge.js +50 -0
- package/dist/cli/dashboard/index.js +11 -0
- package/dist/cli/dashboard/types.js +2 -0
- package/dist/cli/tui.js +48 -0
- package/dist/commands/ai-prompt.js +22 -0
- package/dist/commands/analyze-deps.js +138 -0
- package/dist/commands/archive.js +113 -0
- package/dist/commands/branch/create.js +119 -0
- package/dist/commands/branch/index.js +104 -0
- package/dist/commands/branch/merge.js +90 -0
- package/dist/commands/branch/rebase.js +84 -0
- package/dist/commands/branch/switch.js +165 -0
- package/dist/commands/cache/index.js +80 -0
- package/dist/commands/cache/manager.js +198 -0
- package/dist/commands/cleanup.js +153 -0
- package/dist/commands/configure-claude.js +55 -0
- package/dist/commands/configure.js +231 -0
- package/dist/commands/create.js +204 -0
- package/dist/commands/dashboard/hook-handler.js +162 -0
- package/dist/commands/dashboard/index.js +258 -0
- package/dist/commands/dashboard/launcher.js +40 -0
- package/dist/commands/dashboard/session-picker.js +106 -0
- package/dist/commands/dashboard/sidebar.js +39 -0
- package/dist/commands/dashboard/workspace-picker.js +95 -0
- package/dist/commands/delete.js +194 -0
- package/dist/commands/deprecated-aliases.js +44 -0
- package/dist/commands/diff.js +129 -0
- package/dist/commands/doctor.js +141 -0
- package/dist/commands/generate-docs.js +77 -0
- package/dist/commands/ghq-status.js +72 -0
- package/dist/commands/go.js +154 -0
- package/dist/commands/history.js +124 -0
- package/dist/commands/list.js +191 -0
- package/dist/commands/mcp/index.js +69 -0
- package/dist/commands/migrate.js +296 -0
- package/dist/commands/remove-repo.js +217 -0
- package/dist/commands/report-bug.js +112 -0
- package/dist/commands/run.js +147 -0
- package/dist/commands/save-context.js +171 -0
- package/dist/commands/sessions.js +129 -0
- package/dist/commands/status.js +138 -0
- package/dist/commands/suite/create.js +212 -0
- package/dist/commands/suite/delete.js +64 -0
- package/dist/commands/suite/export.js +126 -0
- package/dist/commands/suite/import.js +128 -0
- package/dist/commands/suite/index.js +94 -0
- package/dist/commands/suite/list.js +49 -0
- package/dist/commands/suite/use.js +227 -0
- package/dist/commands/sync.js +117 -0
- package/dist/commands/update.js +203 -0
- package/dist/mcp/install.js +550 -0
- package/dist/mcp/server.js +398 -0
- package/dist/mcp/tools.js +922 -0
- package/dist/pi-extensions/permission-sync.ts.template +41 -0
- package/dist/pi-extensions/permissions-gate.ts.template +69 -0
- package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/dist/pi-extensions/review-reminder.ts.template +58 -0
- package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
- package/dist/program.js +136 -0
- package/dist/scripts/workspace-table.py +394 -0
- package/dist/types/dashboard.js +11 -0
- package/dist/types/index.js +2 -0
- package/dist/utils/agent-config.js +263 -0
- package/dist/utils/agent-state.js +178 -0
- package/dist/utils/banner.js +24 -0
- package/dist/utils/branch-operations.js +232 -0
- package/dist/utils/bug-report.js +355 -0
- package/dist/utils/cache.js +168 -0
- package/dist/utils/claude-integration.js +617 -0
- package/dist/utils/claude-sessions.js +260 -0
- package/dist/utils/cleanup-operations.js +120 -0
- package/dist/utils/colors.js +26 -0
- package/dist/utils/command-helpers.js +44 -0
- package/dist/utils/config.js +150 -0
- package/dist/utils/context-file.js +33 -0
- package/dist/utils/dashboard-hooks.js +172 -0
- package/dist/utils/dependency-analyzer.js +234 -0
- package/dist/utils/diff-operations.js +80 -0
- package/dist/utils/doc-generator.js +184 -0
- package/dist/utils/ghq-integration.js +332 -0
- package/dist/utils/git-operations.js +237 -0
- package/dist/utils/git-status.js +187 -0
- package/dist/utils/github.js +68 -0
- package/dist/utils/health-checks.js +345 -0
- package/dist/utils/history.js +150 -0
- package/dist/utils/hooks.js +100 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/permission-sync.js +681 -0
- package/dist/utils/pi-extensions.js +224 -0
- package/dist/utils/progress.js +92 -0
- package/dist/utils/prompts.js +279 -0
- package/dist/utils/repo-resolver.js +260 -0
- package/dist/utils/retry.js +65 -0
- package/dist/utils/run-operations.js +30 -0
- package/dist/utils/suite.js +159 -0
- package/dist/utils/sync-operations.js +107 -0
- package/dist/utils/tmux-dashboard.js +184 -0
- package/dist/utils/validation.js +153 -0
- package/dist/utils/version-check.js +121 -0
- package/dist/utils/workspace-meta.js +185 -0
- package/install-shell-integration.sh +532 -0
- package/install.sh +401 -0
- package/package.json +90 -0
- package/remove-shell-block.awk +44 -0
- package/scripts/postinstall.js +85 -0
- package/skills/analyze-deps.md +23 -0
- package/skills/archive-workspace.md +26 -0
- package/skills/branch-create.md +18 -0
- package/skills/create-workspace.md +39 -0
- package/skills/delete-workspace.md +37 -0
- package/skills/list-org-repos.md +26 -0
- package/skills/list-suites.md +24 -0
- package/skills/list-workspaces.md +27 -0
- package/skills/refresh-workspace-docs.md +89 -0
- package/skills/remove-repo.md +25 -0
- package/skills/run-command.md +23 -0
- package/skills/save-context.md +68 -0
- package/skills/search-repos.md +31 -0
- package/skills/snapshot-list.md +18 -0
- package/skills/snapshot-save.md +12 -0
- package/skills/switch-branch.md +18 -0
- package/skills/update-cache.md +21 -0
- package/skills/update-workspace.md +66 -0
- package/skills/workspace-cleanup.md +18 -0
- package/skills/workspace-diff.md +23 -0
- package/skills/workspace-doctor.md +23 -0
- package/skills/workspace-info.md +45 -0
- package/skills/workspace-manager/SKILL.md +201 -0
- package/skills/workspace-manager/references/ai-prompt.md +44 -0
- package/skills/workspace-manager/references/analyze-deps.md +45 -0
- package/skills/workspace-manager/references/archive-workspace.md +38 -0
- package/skills/workspace-manager/references/branch-create.md +31 -0
- package/skills/workspace-manager/references/branch-merge.md +22 -0
- package/skills/workspace-manager/references/branch-rebase.md +22 -0
- package/skills/workspace-manager/references/cache-clear.md +18 -0
- package/skills/workspace-manager/references/cache-info.md +22 -0
- package/skills/workspace-manager/references/configure-claude.md +31 -0
- package/skills/workspace-manager/references/configure.md +35 -0
- package/skills/workspace-manager/references/create-workspace.md +45 -0
- package/skills/workspace-manager/references/dashboard.md +61 -0
- package/skills/workspace-manager/references/delete-workspace.md +33 -0
- package/skills/workspace-manager/references/generate-docs.md +36 -0
- package/skills/workspace-manager/references/ghq-status.md +28 -0
- package/skills/workspace-manager/references/go.md +33 -0
- package/skills/workspace-manager/references/history.md +41 -0
- package/skills/workspace-manager/references/list-org-repos.md +26 -0
- package/skills/workspace-manager/references/list-suites.md +21 -0
- package/skills/workspace-manager/references/list-workspaces.md +27 -0
- package/skills/workspace-manager/references/mcp.md +49 -0
- package/skills/workspace-manager/references/remove-repo.md +26 -0
- package/skills/workspace-manager/references/run-command.md +44 -0
- package/skills/workspace-manager/references/search-repos.md +24 -0
- package/skills/workspace-manager/references/sessions.md +27 -0
- package/skills/workspace-manager/references/suite-create.md +22 -0
- package/skills/workspace-manager/references/suite-delete.md +21 -0
- package/skills/workspace-manager/references/suite-export.md +19 -0
- package/skills/workspace-manager/references/suite-import.md +19 -0
- package/skills/workspace-manager/references/suite-use.md +27 -0
- package/skills/workspace-manager/references/switch-branch.md +25 -0
- package/skills/workspace-manager/references/update-cache.md +22 -0
- package/skills/workspace-manager/references/update-workspace.md +50 -0
- package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
- package/skills/workspace-manager/references/workspace-diff.md +27 -0
- package/skills/workspace-manager/references/workspace-doctor.md +27 -0
- package/skills/workspace-manager/references/workspace-info.md +33 -0
- package/skills/workspace-manager/references/workspace-status.md +43 -0
- package/skills/workspace-manager/references/workspace-sync.md +26 -0
- package/skills/workspace-status.md +26 -0
- package/skills/workspace-sync.md +23 -0
- package/src/cli/ai-prompt.test.ts +667 -0
- package/src/cli/ai-prompt.ts +421 -0
- package/src/cli/dashboard/AgentList.tsx +44 -0
- package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
- package/src/cli/dashboard/HelpFooter.tsx +22 -0
- package/src/cli/dashboard/StatusBadge.tsx +23 -0
- package/src/cli/dashboard/index.ts +5 -0
- package/src/cli/dashboard/types.ts +16 -0
- package/src/cli/tui.tsx +51 -0
- package/src/cli/workspace-cli.test.ts +30 -0
- package/src/commands/ai-prompt.ts +21 -0
- package/src/commands/analyze-deps.ts +119 -0
- package/src/commands/archive.ts +114 -0
- package/src/commands/branch/create.ts +94 -0
- package/src/commands/branch/index.ts +74 -0
- package/src/commands/branch/merge.ts +61 -0
- package/src/commands/branch/rebase.ts +54 -0
- package/src/commands/branch/switch.ts +150 -0
- package/src/commands/cache/index.ts +51 -0
- package/src/commands/cache/manager.ts +168 -0
- package/src/commands/cleanup.ts +132 -0
- package/src/commands/configure-claude.ts +55 -0
- package/src/commands/configure.ts +204 -0
- package/src/commands/create.ts +196 -0
- package/src/commands/dashboard/hook-handler.ts +138 -0
- package/src/commands/dashboard/index.ts +248 -0
- package/src/commands/dashboard/launcher.ts +42 -0
- package/src/commands/dashboard/session-picker.ts +73 -0
- package/src/commands/dashboard/sidebar.js +39 -0
- package/src/commands/dashboard/workspace-picker.ts +62 -0
- package/src/commands/delete.test.ts +250 -0
- package/src/commands/delete.ts +171 -0
- package/src/commands/deprecated-aliases.ts +55 -0
- package/src/commands/diff.ts +106 -0
- package/src/commands/doctor.ts +110 -0
- package/src/commands/generate-docs.ts +45 -0
- package/src/commands/ghq-status.ts +70 -0
- package/src/commands/go.ts +125 -0
- package/src/commands/history.ts +136 -0
- package/src/commands/list.test.ts +219 -0
- package/src/commands/list.ts +176 -0
- package/src/commands/mcp/index.ts +39 -0
- package/src/commands/migrate.ts +277 -0
- package/src/commands/remove-repo.ts +194 -0
- package/src/commands/report-bug.ts +88 -0
- package/src/commands/run.ts +125 -0
- package/src/commands/save-context.test.ts +113 -0
- package/src/commands/save-context.ts +141 -0
- package/src/commands/sessions.ts +99 -0
- package/src/commands/status.ts +111 -0
- package/src/commands/suite/create.ts +240 -0
- package/src/commands/suite/delete.ts +65 -0
- package/src/commands/suite/export.ts +97 -0
- package/src/commands/suite/import.ts +100 -0
- package/src/commands/suite/index.ts +66 -0
- package/src/commands/suite/list.ts +54 -0
- package/src/commands/suite/use.ts +232 -0
- package/src/commands/sync.ts +97 -0
- package/src/commands/update.ts +197 -0
- package/src/mcp/install.ts +529 -0
- package/src/mcp/server.ts +579 -0
- package/src/mcp/tools.test.ts +545 -0
- package/src/mcp/tools.ts +1019 -0
- package/src/mcp/update-workspace.test.ts +220 -0
- package/src/pi-extensions/permission-sync.ts.template +41 -0
- package/src/pi-extensions/permissions-gate.ts.template +69 -0
- package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
- package/src/pi-extensions/review-reminder.ts.template +58 -0
- package/src/pi-extensions/workspace-input-status.ts.template +919 -0
- package/src/program.ts +112 -0
- package/src/scripts/workspace-table.py +394 -0
- package/src/types/dashboard.ts +29 -0
- package/src/types/index.ts +109 -0
- package/src/types/ink.d.ts +171 -0
- package/src/utils/agent-config.test.ts +303 -0
- package/src/utils/agent-config.ts +264 -0
- package/src/utils/agent-state.test.ts +166 -0
- package/src/utils/agent-state.ts +135 -0
- package/src/utils/banner.ts +24 -0
- package/src/utils/branch-operations.ts +237 -0
- package/src/utils/bug-report.test.ts +174 -0
- package/src/utils/bug-report.ts +355 -0
- package/src/utils/cache.ts +150 -0
- package/src/utils/claude-integration.test.ts +381 -0
- package/src/utils/claude-integration.ts +649 -0
- package/src/utils/claude-sessions.test.ts +262 -0
- package/src/utils/claude-sessions.ts +251 -0
- package/src/utils/cleanup-operations.ts +94 -0
- package/src/utils/colors.ts +25 -0
- package/src/utils/command-helpers.ts +46 -0
- package/src/utils/config.test.ts +203 -0
- package/src/utils/config.ts +133 -0
- package/src/utils/context-file.ts +36 -0
- package/src/utils/dashboard-hooks.test.ts +168 -0
- package/src/utils/dashboard-hooks.ts +163 -0
- package/src/utils/dependency-analyzer.ts +243 -0
- package/src/utils/diff-operations.ts +59 -0
- package/src/utils/doc-generator.ts +187 -0
- package/src/utils/ghq-integration.test.ts +195 -0
- package/src/utils/ghq-integration.ts +317 -0
- package/src/utils/git-operations.test.ts +265 -0
- package/src/utils/git-operations.ts +233 -0
- package/src/utils/git-status.ts +171 -0
- package/src/utils/github.ts +74 -0
- package/src/utils/health-checks.ts +359 -0
- package/src/utils/history.ts +140 -0
- package/src/utils/hooks.ts +92 -0
- package/src/utils/logger.ts +37 -0
- package/src/utils/permission-sync.test.ts +593 -0
- package/src/utils/permission-sync.ts +720 -0
- package/src/utils/pi-extensions.test.ts +182 -0
- package/src/utils/pi-extensions.ts +226 -0
- package/src/utils/progress.ts +73 -0
- package/src/utils/prompts.test.ts +169 -0
- package/src/utils/prompts.ts +296 -0
- package/src/utils/repo-resolver.test.ts +313 -0
- package/src/utils/repo-resolver.ts +294 -0
- package/src/utils/retry.test.ts +124 -0
- package/src/utils/retry.ts +85 -0
- package/src/utils/run-operations.ts +38 -0
- package/src/utils/suite.test.ts +256 -0
- package/src/utils/suite.ts +144 -0
- package/src/utils/sync-operations.ts +87 -0
- package/src/utils/tmux-dashboard.ts +193 -0
- package/src/utils/validation.test.ts +139 -0
- package/src/utils/validation.ts +128 -0
- package/src/utils/version-check.test.ts +108 -0
- package/src/utils/version-check.ts +96 -0
- package/src/utils/workspace-meta.ts +173 -0
- package/sync-permissions.sh +137 -0
- package/tsconfig.json +20 -0
- package/uninstall.sh +162 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace input status — Claude-Code-style layout (auto-generated by workspace-manager)
|
|
3
|
+
*
|
|
4
|
+
* Layout (matching the Claude Code reference):
|
|
5
|
+
*
|
|
6
|
+
* ──────────────────────────[ workspace-name ]────── ← editor top border
|
|
7
|
+
* │ > _ │ ← input box
|
|
8
|
+
* ────────────────────────────────────────────────── ← editor bottom border
|
|
9
|
+
* ⠋ ✦ sonnet-4-6 ⚡ high 📂 ws:foo (3) 🧠 24k/200k 34% 💰 $0.42 ⏱ 2m 5s
|
|
10
|
+
*
|
|
11
|
+
* REPO BRANCH PR DIRTY
|
|
12
|
+
* workspace-manager-fix-… feat/oauth #41 open ✓
|
|
13
|
+
* ▸ workspace-manager-security security/x #45 draft *2
|
|
14
|
+
*
|
|
15
|
+
* [pi's default footer renders below — untouched]
|
|
16
|
+
*
|
|
17
|
+
* Design choices:
|
|
18
|
+
* - Workspace name lives in the editor's top border (Claude-Code style "tag")
|
|
19
|
+
* - Status line + table sit BELOW the editor (user request)
|
|
20
|
+
* - Thinking level visible (`⚡ high`)
|
|
21
|
+
* - Column headers visible in `muted` (not `dim`) for legibility
|
|
22
|
+
* - Icons rendered uncolored (emojis show native colors), text rendered in
|
|
23
|
+
* semantic theme colors — no inverse/bg surprises
|
|
24
|
+
* - Column widths computed on plain text; OSC 8 + colors applied AFTER pad
|
|
25
|
+
*
|
|
26
|
+
* Filter: only repos with PR AND not on default branch. /wsrepos toggles full.
|
|
27
|
+
*
|
|
28
|
+
* Commands: /wsrepos /wsrefresh /wsopen
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
32
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { dirname, join } from "node:path";
|
|
35
|
+
import {
|
|
36
|
+
CustomEditor,
|
|
37
|
+
type ExtensionAPI,
|
|
38
|
+
type ExtensionContext,
|
|
39
|
+
type KeybindingsManager,
|
|
40
|
+
} from "@earendil-works/pi-coding-agent";
|
|
41
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
42
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
43
|
+
|
|
44
|
+
// ─── Glyphs ─────────────────────────────────────────────────────────────────
|
|
45
|
+
// Status line uses universal emojis (work in all terminals & don't fight ANSI fg).
|
|
46
|
+
const G = {
|
|
47
|
+
model: "✦",
|
|
48
|
+
thinking: "⚡",
|
|
49
|
+
workspace: "📂",
|
|
50
|
+
folder: "📁",
|
|
51
|
+
context: "🧠",
|
|
52
|
+
cost: "💰",
|
|
53
|
+
clock: "⏱",
|
|
54
|
+
branch: "🌿",
|
|
55
|
+
activeMark: "▸",
|
|
56
|
+
// Repo table uses gh-dash-style Nerd Font glyphs for crisp PR/CI states.
|
|
57
|
+
prOpen: "", // nf-cod-git_pull_request
|
|
58
|
+
prClosed: "", // nf-cod-git_pull_request_closed
|
|
59
|
+
prMerged: "", // nf-oct-git_merge
|
|
60
|
+
prDraft: "", // nf-oct-git_pull_request_draft
|
|
61
|
+
ciOk: "✓",
|
|
62
|
+
ciFail: "✗",
|
|
63
|
+
ciPending: "◌",
|
|
64
|
+
commentIcon: "",
|
|
65
|
+
dirty: "*",
|
|
66
|
+
ahead: "↑",
|
|
67
|
+
behind: "↓",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const DEFAULT_BRANCHES = new Set([
|
|
71
|
+
"main",
|
|
72
|
+
"master",
|
|
73
|
+
"develop",
|
|
74
|
+
"production",
|
|
75
|
+
"trunk",
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// ─── Workspace metadata ─────────────────────────────────────────────────────
|
|
79
|
+
type WorkspaceRepoMeta = {
|
|
80
|
+
name: string;
|
|
81
|
+
directoryName: string;
|
|
82
|
+
owner: string;
|
|
83
|
+
cloneUrl?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type Workspace = {
|
|
87
|
+
workspaceName: string;
|
|
88
|
+
root: string;
|
|
89
|
+
repositories: WorkspaceRepoMeta[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function findWorkspace(cwd: string): Workspace | undefined {
|
|
93
|
+
let dir = cwd;
|
|
94
|
+
for (let i = 0; i < 12; i++) {
|
|
95
|
+
const meta = join(dir, ".workspace-meta.json");
|
|
96
|
+
if (existsSync(meta)) {
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(readFileSync(meta, "utf8")) as {
|
|
99
|
+
workspaceName?: string;
|
|
100
|
+
repositories?: WorkspaceRepoMeta[];
|
|
101
|
+
};
|
|
102
|
+
if (data.workspaceName && Array.isArray(data.repositories)) {
|
|
103
|
+
return {
|
|
104
|
+
workspaceName: data.workspaceName,
|
|
105
|
+
root: dir,
|
|
106
|
+
repositories: data.repositories,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore malformed file */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const parent = dirname(dir);
|
|
114
|
+
if (parent === dir) break;
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
121
|
+
function abbreviatePath(cwd: string): string {
|
|
122
|
+
const home = homedir();
|
|
123
|
+
let path = cwd;
|
|
124
|
+
if (path === home) return "~";
|
|
125
|
+
if (path.startsWith(`${home}/`)) path = `~${path.slice(home.length)}`;
|
|
126
|
+
const parts = path.split("/");
|
|
127
|
+
if (parts.length > 4) {
|
|
128
|
+
return [parts[0], "…", parts[parts.length - 2], parts[parts.length - 1]].join(
|
|
129
|
+
"/",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return path;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function shortModel(id: string | undefined): string {
|
|
136
|
+
if (!id) return "no model";
|
|
137
|
+
const tail = id.split(".").pop() ?? id;
|
|
138
|
+
return tail.replace(/^claude-/, "").replace(/-v\d+$/, "");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function osc8(url: string, text: string): string {
|
|
142
|
+
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatDuration(ms: number): string {
|
|
146
|
+
const s = Math.floor(ms / 1000);
|
|
147
|
+
const h = Math.floor(s / 3600);
|
|
148
|
+
const m = Math.floor((s % 3600) / 60);
|
|
149
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
150
|
+
if (m > 0) return `${m}m`;
|
|
151
|
+
return `${s}s`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function fmtTokens(n: number): string {
|
|
155
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
156
|
+
return String(n);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type ThemeApi = {
|
|
160
|
+
fg: (color: string, text: string) => string;
|
|
161
|
+
bg: (color: string, text: string) => string;
|
|
162
|
+
bold: (text: string) => string;
|
|
163
|
+
getBgAnsi: (color: string) => string;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
function sessionStats(ctx: ExtensionContext): {
|
|
167
|
+
input: number;
|
|
168
|
+
output: number;
|
|
169
|
+
cost: number;
|
|
170
|
+
} {
|
|
171
|
+
let input = 0;
|
|
172
|
+
let output = 0;
|
|
173
|
+
let cost = 0;
|
|
174
|
+
for (const e of ctx.sessionManager.getBranch()) {
|
|
175
|
+
if (e.type === "message" && e.message.role === "assistant") {
|
|
176
|
+
const m = e.message as AssistantMessage;
|
|
177
|
+
input += m.usage.input ?? 0;
|
|
178
|
+
output += m.usage.output ?? 0;
|
|
179
|
+
cost += m.usage.cost?.total ?? 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { input, output, cost };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Render a small "[ tag ]" pill on the editor's top border. */
|
|
186
|
+
function topBorderTag(theme: ThemeApi, label: string): string {
|
|
187
|
+
const fgKey = "borderAccent"; // pink in dracula → matches CC tag color
|
|
188
|
+
const bgKey = "selectedBg"; // currentLine in dracula → subtle frame
|
|
189
|
+
const bgAnsi = theme.getBgAnsi(bgKey);
|
|
190
|
+
const edgeFg = bgAnsi.replace(/\x1b\[48;/, "\x1b[38;");
|
|
191
|
+
const fgReset = "\x1b[39m";
|
|
192
|
+
const body = theme.bg(bgKey, ` ${theme.bold(theme.fg(fgKey, label))} `);
|
|
193
|
+
return `${edgeFg}\uE0B6${fgReset}${body}${edgeFg}\uE0B4${fgReset}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Place left- and right-aligned content on a horizontal border line. */
|
|
197
|
+
function fitBorder(
|
|
198
|
+
left: string,
|
|
199
|
+
right: string,
|
|
200
|
+
width: number,
|
|
201
|
+
border: (text: string) => string,
|
|
202
|
+
): string {
|
|
203
|
+
if (width <= 0) return "";
|
|
204
|
+
if (width === 1) return border("─");
|
|
205
|
+
let leftText = left;
|
|
206
|
+
let rightText = right;
|
|
207
|
+
const fixed = 2;
|
|
208
|
+
const minGap = 1;
|
|
209
|
+
while (
|
|
210
|
+
fixed + visibleWidth(leftText) + visibleWidth(rightText) + minGap > width &&
|
|
211
|
+
visibleWidth(rightText) > 0
|
|
212
|
+
) {
|
|
213
|
+
rightText = truncateToWidth(rightText, Math.max(0, visibleWidth(rightText) - 1), "");
|
|
214
|
+
}
|
|
215
|
+
while (
|
|
216
|
+
fixed + visibleWidth(leftText) + visibleWidth(rightText) + minGap > width &&
|
|
217
|
+
visibleWidth(leftText) > 0
|
|
218
|
+
) {
|
|
219
|
+
leftText = truncateToWidth(leftText, Math.max(0, visibleWidth(leftText) - 1), "");
|
|
220
|
+
}
|
|
221
|
+
const gap = Math.max(
|
|
222
|
+
0,
|
|
223
|
+
width - fixed - visibleWidth(leftText) - visibleWidth(rightText),
|
|
224
|
+
);
|
|
225
|
+
return `${border("─")}${leftText}${border("─".repeat(gap))}${rightText}${border("─")}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Repo state ─────────────────────────────────────────────────────────────
|
|
229
|
+
type CIStatus = "success" | "failure" | "pending" | "none";
|
|
230
|
+
|
|
231
|
+
type PR = {
|
|
232
|
+
number: number;
|
|
233
|
+
url: string;
|
|
234
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
235
|
+
isDraft: boolean;
|
|
236
|
+
commentCount: number;
|
|
237
|
+
ci: CIStatus;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
type RepoState = WorkspaceRepoMeta & {
|
|
241
|
+
branch?: string;
|
|
242
|
+
branchUrl?: string;
|
|
243
|
+
pr?: PR;
|
|
244
|
+
dirty: number;
|
|
245
|
+
ahead: number;
|
|
246
|
+
behind: number;
|
|
247
|
+
loading: boolean;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// ─── Extension ──────────────────────────────────────────────────────────────
|
|
251
|
+
export default function (pi: ExtensionAPI) {
|
|
252
|
+
let isWorking = false;
|
|
253
|
+
let isShutdown = false; // set on session_shutdown to guard stale pi.exec() calls
|
|
254
|
+
let hasUiSession = false; // true only inside an interactive TUI session — gates ALL reactive work (agent_start/agent_end refreshes, timers)
|
|
255
|
+
let spinnerIdx = 0;
|
|
256
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
257
|
+
let activeTui: TUI | undefined;
|
|
258
|
+
const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
259
|
+
|
|
260
|
+
const stopSpinner = () => {
|
|
261
|
+
if (spinnerTimer) {
|
|
262
|
+
clearInterval(spinnerTimer);
|
|
263
|
+
spinnerTimer = undefined;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
let workspace: Workspace | undefined;
|
|
268
|
+
let repoStates: RepoState[] = [];
|
|
269
|
+
let showOnlyInteresting = true;
|
|
270
|
+
let cwd = process.cwd();
|
|
271
|
+
let currentBranch: string | undefined;
|
|
272
|
+
let currentBranchDirty = 0;
|
|
273
|
+
let sessionStartedAt = Date.now();
|
|
274
|
+
let sessionTickTimer: ReturnType<typeof setInterval> | undefined;
|
|
275
|
+
|
|
276
|
+
async function getAheadBehind(
|
|
277
|
+
dir: string,
|
|
278
|
+
): Promise<{ ahead: number; behind: number }> {
|
|
279
|
+
for (const ref of ["origin/HEAD", "origin/main", "origin/master"]) {
|
|
280
|
+
const r = await pi
|
|
281
|
+
.exec(
|
|
282
|
+
"git",
|
|
283
|
+
["rev-list", "--left-right", "--count", `${ref}...HEAD`],
|
|
284
|
+
{ cwd: dir, timeout: 3000 },
|
|
285
|
+
)
|
|
286
|
+
.catch(() => undefined);
|
|
287
|
+
if (r?.code === 0) {
|
|
288
|
+
const [behindStr, aheadStr] = r.stdout.trim().split(/\s+/);
|
|
289
|
+
const behind = Number(behindStr ?? 0);
|
|
290
|
+
const ahead = Number(aheadStr ?? 0);
|
|
291
|
+
if (Number.isFinite(behind) && Number.isFinite(ahead))
|
|
292
|
+
return { ahead, behind };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return { ahead: 0, behind: 0 };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function getRepoWebUrl(dir: string): Promise<string | undefined> {
|
|
299
|
+
const r = await pi
|
|
300
|
+
.exec("git", ["remote", "get-url", "origin"], { cwd: dir, timeout: 2000 })
|
|
301
|
+
.catch(() => undefined);
|
|
302
|
+
if (r?.code !== 0) return undefined;
|
|
303
|
+
let url = r.stdout.trim();
|
|
304
|
+
if (url.startsWith("git@")) {
|
|
305
|
+
const [host, path] = url.slice(4).split(":", 2);
|
|
306
|
+
url = `https://${host}/${path}`;
|
|
307
|
+
}
|
|
308
|
+
if (url.endsWith(".git")) url = url.slice(0, -4);
|
|
309
|
+
return url;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function rollupCi(rollup: unknown): CIStatus {
|
|
313
|
+
if (!Array.isArray(rollup) || rollup.length === 0) return "none";
|
|
314
|
+
let hasFail = false;
|
|
315
|
+
let hasPending = false;
|
|
316
|
+
for (const c of rollup as Array<Record<string, unknown>>) {
|
|
317
|
+
const state =
|
|
318
|
+
(c.state as string) ||
|
|
319
|
+
(c.conclusion as string) ||
|
|
320
|
+
(c.status as string) ||
|
|
321
|
+
"";
|
|
322
|
+
const s = state.toUpperCase();
|
|
323
|
+
if (s === "FAILURE" || s === "TIMED_OUT" || s === "CANCELLED" || s === "ERROR")
|
|
324
|
+
hasFail = true;
|
|
325
|
+
else if (
|
|
326
|
+
s === "PENDING" ||
|
|
327
|
+
s === "IN_PROGRESS" ||
|
|
328
|
+
s === "QUEUED" ||
|
|
329
|
+
s === "" ||
|
|
330
|
+
s === "EXPECTED"
|
|
331
|
+
)
|
|
332
|
+
hasPending = true;
|
|
333
|
+
}
|
|
334
|
+
if (hasFail) return "failure";
|
|
335
|
+
if (hasPending) return "pending";
|
|
336
|
+
return "success";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function refreshRepo(state: RepoState): Promise<void> {
|
|
340
|
+
if (isShutdown || !workspace) return;
|
|
341
|
+
const dir = join(workspace.root, state.directoryName);
|
|
342
|
+
state.loading = true;
|
|
343
|
+
try {
|
|
344
|
+
const branchRes = await pi
|
|
345
|
+
.exec("git", ["branch", "--show-current"], { cwd: dir, timeout: 3000 })
|
|
346
|
+
.catch(() => undefined);
|
|
347
|
+
const branch = branchRes?.stdout.trim();
|
|
348
|
+
state.branch = branch && branch.length > 0 ? branch : undefined;
|
|
349
|
+
state.pr = undefined;
|
|
350
|
+
|
|
351
|
+
const [statusRes, ab, repoUrl, prRes] = await Promise.all([
|
|
352
|
+
pi
|
|
353
|
+
.exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
|
|
354
|
+
.catch(() => undefined),
|
|
355
|
+
getAheadBehind(dir),
|
|
356
|
+
getRepoWebUrl(dir),
|
|
357
|
+
state.branch
|
|
358
|
+
? pi
|
|
359
|
+
.exec(
|
|
360
|
+
"gh",
|
|
361
|
+
[
|
|
362
|
+
"pr",
|
|
363
|
+
"list",
|
|
364
|
+
"--head",
|
|
365
|
+
state.branch,
|
|
366
|
+
"--state",
|
|
367
|
+
"all",
|
|
368
|
+
"--json",
|
|
369
|
+
"number,url,state,isDraft,statusCheckRollup,comments",
|
|
370
|
+
"--limit",
|
|
371
|
+
"1",
|
|
372
|
+
],
|
|
373
|
+
{ cwd: dir, timeout: 8000 },
|
|
374
|
+
)
|
|
375
|
+
.catch(() => undefined)
|
|
376
|
+
: Promise.resolve(undefined),
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
state.dirty = statusRes?.stdout
|
|
380
|
+
? statusRes.stdout
|
|
381
|
+
.split("\n")
|
|
382
|
+
.filter((l: string) => l.trim().length > 0).length
|
|
383
|
+
: 0;
|
|
384
|
+
state.ahead = ab.ahead;
|
|
385
|
+
state.behind = ab.behind;
|
|
386
|
+
state.branchUrl =
|
|
387
|
+
repoUrl && state.branch ? `${repoUrl}/tree/${state.branch}` : undefined;
|
|
388
|
+
|
|
389
|
+
if (prRes?.code === 0 && prRes.stdout.trim()) {
|
|
390
|
+
try {
|
|
391
|
+
const arr = JSON.parse(prRes.stdout) as Array<{
|
|
392
|
+
number: number;
|
|
393
|
+
url: string;
|
|
394
|
+
state: PR["state"];
|
|
395
|
+
isDraft: boolean;
|
|
396
|
+
statusCheckRollup?: unknown;
|
|
397
|
+
comments?: unknown;
|
|
398
|
+
}>;
|
|
399
|
+
if (arr.length > 0) {
|
|
400
|
+
const raw = arr[0];
|
|
401
|
+
state.pr = {
|
|
402
|
+
number: raw.number,
|
|
403
|
+
url: raw.url,
|
|
404
|
+
state: raw.state,
|
|
405
|
+
isDraft: raw.isDraft,
|
|
406
|
+
ci: rollupCi(raw.statusCheckRollup),
|
|
407
|
+
commentCount: Array.isArray(raw.comments)
|
|
408
|
+
? raw.comments.length
|
|
409
|
+
: 0,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
} catch {
|
|
413
|
+
/* ignore */
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
} finally {
|
|
417
|
+
state.loading = false;
|
|
418
|
+
activeTui?.requestRender();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async function refreshAllRepos(): Promise<void> {
|
|
423
|
+
if (!workspace) return;
|
|
424
|
+
await Promise.all(repoStates.map(refreshRepo));
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function refreshCurrentBranchInfo(): Promise<void> {
|
|
428
|
+
if (isShutdown) return;
|
|
429
|
+
const r = await pi
|
|
430
|
+
.exec("git", ["branch", "--show-current"], { cwd, timeout: 3000 })
|
|
431
|
+
.catch(() => undefined);
|
|
432
|
+
const b = r?.stdout.trim();
|
|
433
|
+
currentBranch = b && b.length > 0 ? b : undefined;
|
|
434
|
+
|
|
435
|
+
const s = await pi
|
|
436
|
+
.exec("git", ["status", "--porcelain"], { cwd, timeout: 3000 })
|
|
437
|
+
.catch(() => undefined);
|
|
438
|
+
currentBranchDirty = s?.stdout
|
|
439
|
+
? s.stdout.split("\n").filter((l: string) => l.trim().length > 0).length
|
|
440
|
+
: 0;
|
|
441
|
+
activeTui?.requestRender();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
pi.on("agent_start", () => {
|
|
445
|
+
if (!hasUiSession) return;
|
|
446
|
+
isWorking = true;
|
|
447
|
+
stopSpinner();
|
|
448
|
+
spinnerTimer = setInterval(() => {
|
|
449
|
+
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
|
450
|
+
activeTui?.requestRender();
|
|
451
|
+
}, 80);
|
|
452
|
+
activeTui?.requestRender();
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
pi.on("agent_end", () => {
|
|
456
|
+
if (!hasUiSession) return;
|
|
457
|
+
isWorking = false;
|
|
458
|
+
stopSpinner();
|
|
459
|
+
activeTui?.requestRender();
|
|
460
|
+
void refreshAllRepos();
|
|
461
|
+
void refreshCurrentBranchInfo();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
pi.on("session_shutdown", () => {
|
|
465
|
+
isShutdown = true;
|
|
466
|
+
hasUiSession = false;
|
|
467
|
+
stopSpinner();
|
|
468
|
+
if (sessionTickTimer) {
|
|
469
|
+
clearInterval(sessionTickTimer);
|
|
470
|
+
sessionTickTimer = undefined;
|
|
471
|
+
}
|
|
472
|
+
activeTui = undefined;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
pi.registerCommand("wsrepos", {
|
|
476
|
+
description: "Toggle workspace repo widget: filtered ↔ full",
|
|
477
|
+
handler: async (_args, ctx) => {
|
|
478
|
+
showOnlyInteresting = !showOnlyInteresting;
|
|
479
|
+
activeTui?.requestRender();
|
|
480
|
+
ctx.ui.notify(
|
|
481
|
+
`Workspace widget: ${showOnlyInteresting ? "filtered (PR + non-default)" : "full"}`,
|
|
482
|
+
"info",
|
|
483
|
+
);
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
pi.registerCommand("wsrefresh", {
|
|
488
|
+
description: "Refresh workspace repo branches/PRs",
|
|
489
|
+
handler: async (_args, ctx) => {
|
|
490
|
+
ctx.ui.notify("Refreshing…", "info");
|
|
491
|
+
await Promise.all([refreshAllRepos(), refreshCurrentBranchInfo()]);
|
|
492
|
+
ctx.ui.notify("Refreshed", "info");
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
pi.registerCommand("wsopen", {
|
|
497
|
+
description: "Open a repo's PR (or branch) in browser. Usage: /wsopen [repo]",
|
|
498
|
+
handler: async (args, ctx) => {
|
|
499
|
+
if (!workspace) {
|
|
500
|
+
ctx.ui.notify("Not in a workspace", "warning");
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const query = (args ?? "").trim();
|
|
504
|
+
let target: RepoState | undefined;
|
|
505
|
+
if (!query) {
|
|
506
|
+
target = repoStates.find((r) => {
|
|
507
|
+
const p = join(workspace!.root, r.directoryName);
|
|
508
|
+
return cwd === p || cwd.startsWith(`${p}/`);
|
|
509
|
+
});
|
|
510
|
+
if (!target) {
|
|
511
|
+
ctx.ui.notify(
|
|
512
|
+
"Not inside a specific repo. Try: /wsopen <name>",
|
|
513
|
+
"warning",
|
|
514
|
+
);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
const q = query.toLowerCase();
|
|
519
|
+
target =
|
|
520
|
+
repoStates.find((r) => r.directoryName.toLowerCase() === q) ||
|
|
521
|
+
repoStates.find((r) => r.directoryName.toLowerCase().startsWith(q)) ||
|
|
522
|
+
repoStates.find((r) => r.directoryName.toLowerCase().includes(q));
|
|
523
|
+
}
|
|
524
|
+
if (!target) {
|
|
525
|
+
ctx.ui.notify(`No matching repo for "${query}"`, "warning");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
let url: string;
|
|
529
|
+
let kind: string;
|
|
530
|
+
if (target.pr) {
|
|
531
|
+
url = target.pr.url;
|
|
532
|
+
kind = `PR #${target.pr.number}`;
|
|
533
|
+
} else if (target.branchUrl) {
|
|
534
|
+
url = target.branchUrl;
|
|
535
|
+
kind = `branch ${target.branch}`;
|
|
536
|
+
} else {
|
|
537
|
+
url = `https://github.com/${target.owner}/${target.name}`;
|
|
538
|
+
kind = "repo";
|
|
539
|
+
}
|
|
540
|
+
const opener =
|
|
541
|
+
process.platform === "darwin"
|
|
542
|
+
? "open"
|
|
543
|
+
: process.platform === "win32"
|
|
544
|
+
? "start"
|
|
545
|
+
: "xdg-open";
|
|
546
|
+
const r = await pi
|
|
547
|
+
.exec(opener, [url], { timeout: 3000 })
|
|
548
|
+
.catch(() => ({ code: -1, stdout: "", stderr: "", killed: false }));
|
|
549
|
+
if (r.code === 0)
|
|
550
|
+
ctx.ui.notify(`Opened ${target.directoryName}: ${kind}`, "info");
|
|
551
|
+
else ctx.ui.notify(`Could not open ${url}`, "error");
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
pi.on("session_start", (_event, ctx) => {
|
|
556
|
+
// Skip the entire widget setup in non-TUI modes (pi -p, JSON mode, RPC).
|
|
557
|
+
// Without this, async refreshes launched below (refreshCurrentBranchInfo,
|
|
558
|
+
// refreshAllRepos) would still be in flight when the print-mode runtime
|
|
559
|
+
// tears down, causing pi.exec() to throw "extension ctx is stale" and
|
|
560
|
+
// crash the whole pi -p process. There is also no UI to render to in
|
|
561
|
+
// these modes, so the work is wasted.
|
|
562
|
+
if (!ctx.hasUI) return;
|
|
563
|
+
hasUiSession = true;
|
|
564
|
+
|
|
565
|
+
cwd = ctx.cwd;
|
|
566
|
+
workspace = findWorkspace(cwd);
|
|
567
|
+
repoStates = workspace
|
|
568
|
+
? workspace.repositories.map((r) => ({
|
|
569
|
+
...r,
|
|
570
|
+
loading: true,
|
|
571
|
+
dirty: 0,
|
|
572
|
+
ahead: 0,
|
|
573
|
+
behind: 0,
|
|
574
|
+
}))
|
|
575
|
+
: [];
|
|
576
|
+
|
|
577
|
+
sessionStartedAt = Date.now();
|
|
578
|
+
if (sessionTickTimer) clearInterval(sessionTickTimer);
|
|
579
|
+
sessionTickTimer = setInterval(() => activeTui?.requestRender(), 30_000);
|
|
580
|
+
|
|
581
|
+
void refreshCurrentBranchInfo();
|
|
582
|
+
void refreshAllRepos();
|
|
583
|
+
|
|
584
|
+
// ─── Custom editor: workspace pill on top border ──────────────────────
|
|
585
|
+
class BorderEditor extends CustomEditor {
|
|
586
|
+
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
|
|
587
|
+
super(tui, theme, keybindings, { paddingX: 0 });
|
|
588
|
+
activeTui = tui;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
render(width: number): string[] {
|
|
592
|
+
const lines = super.render(width);
|
|
593
|
+
if (lines.length < 2 || !workspace) return lines;
|
|
594
|
+
const thm = ctx.ui.theme as unknown as ThemeApi;
|
|
595
|
+
const borderColor = (text: string) => this.borderColor(text);
|
|
596
|
+
|
|
597
|
+
const tag = topBorderTag(thm, workspace.workspaceName);
|
|
598
|
+
lines[0] = fitBorder("", tag, width, borderColor);
|
|
599
|
+
|
|
600
|
+
// Safety: PowerLine glyphs (\uE0B6/\uE0B4) may have uncertain
|
|
601
|
+
// rendered width. Truncate every line to terminal width so Pi
|
|
602
|
+
// never crashes on resize.
|
|
603
|
+
return lines.map((line) =>
|
|
604
|
+
visibleWidth(line) > width
|
|
605
|
+
? truncateToWidth(line, width, "")
|
|
606
|
+
: line
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
ctx.ui.setEditorComponent(
|
|
612
|
+
(tui, theme, keybindings) => new BorderEditor(tui, theme, keybindings),
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// ─── BELOW-EDITOR widget: status line + table ─────────────────────────
|
|
616
|
+
ctx.ui.setWidget(
|
|
617
|
+
"workspace-input-status",
|
|
618
|
+
(tui) => {
|
|
619
|
+
activeTui = tui;
|
|
620
|
+
return {
|
|
621
|
+
render(width: number): string[] {
|
|
622
|
+
const thm = ctx.ui.theme as unknown as ThemeApi;
|
|
623
|
+
const raw: string[] = [];
|
|
624
|
+
|
|
625
|
+
raw.push(renderStatusLine(thm, ctx));
|
|
626
|
+
|
|
627
|
+
if (workspace && workspace.repositories.length > 0) {
|
|
628
|
+
raw.push("");
|
|
629
|
+
raw.push(...renderRepoTable(thm, width));
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Guarantee every line fits within the terminal width.
|
|
633
|
+
// Pi will crash with an uncaughtException if any line
|
|
634
|
+
// exceeds the available width.
|
|
635
|
+
return raw.map((line) =>
|
|
636
|
+
visibleWidth(line) > width
|
|
637
|
+
? truncateToWidth(line, width, "")
|
|
638
|
+
: line
|
|
639
|
+
);
|
|
640
|
+
},
|
|
641
|
+
invalidate(): void {},
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
{ placement: "belowEditor" },
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// ─── Renderers ──────────────────────────────────────────────────────────
|
|
649
|
+
function renderStatusLine(thm: ThemeApi, ctx: ExtensionContext): string {
|
|
650
|
+
// Each segment: an emoji icon (rendered uncolored — emojis paint themselves)
|
|
651
|
+
// followed by the styled text.
|
|
652
|
+
const segs: string[] = [];
|
|
653
|
+
|
|
654
|
+
if (isWorking) {
|
|
655
|
+
segs.push(thm.fg("accent", spinnerFrames[spinnerIdx]));
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Model
|
|
659
|
+
segs.push(
|
|
660
|
+
`${G.model} ${thm.bold(thm.fg("borderAccent", shortModel(ctx.model?.id)))}`,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
// Thinking
|
|
664
|
+
const thinking = pi.getThinkingLevel();
|
|
665
|
+
const thinkingLabel = thinking === "off" ? "off" : thinking;
|
|
666
|
+
const thinkColor =
|
|
667
|
+
thinking === "xhigh" || thinking === "high"
|
|
668
|
+
? "warning"
|
|
669
|
+
: thinking === "off"
|
|
670
|
+
? "dim"
|
|
671
|
+
: "muted";
|
|
672
|
+
segs.push(`${G.thinking} ${thm.fg(thinkColor, thinkingLabel)}`);
|
|
673
|
+
|
|
674
|
+
// Workspace OR path
|
|
675
|
+
if (workspace) {
|
|
676
|
+
const repoCount = workspace.repositories.length;
|
|
677
|
+
// Make the workspace name an OSC 8 hyperlink to its on-disk root, so
|
|
678
|
+
// clicking (cmd/ctrl-click in most terminals) opens the folder in Finder.
|
|
679
|
+
// encodeURI keeps the path separators but escapes spaces etc.; an empty
|
|
680
|
+
// host (file:///abs/path) is the portable form terminals open locally.
|
|
681
|
+
const wsLabel = thm.bold(
|
|
682
|
+
thm.fg("border", `ws:${workspace.workspaceName}`),
|
|
683
|
+
);
|
|
684
|
+
const wsLinked = osc8(`file://${encodeURI(workspace.root)}`, wsLabel);
|
|
685
|
+
segs.push(
|
|
686
|
+
`${G.workspace} ${wsLinked} ${thm.fg("dim", `(${repoCount})`)}`,
|
|
687
|
+
);
|
|
688
|
+
} else {
|
|
689
|
+
segs.push(`${G.folder} ${thm.fg("text", abbreviatePath(cwd))}`);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Context
|
|
693
|
+
const usage = ctx.getContextUsage();
|
|
694
|
+
const pct = usage?.percent ?? null;
|
|
695
|
+
const window = usage?.contextWindow ?? ctx.model?.contextWindow ?? null;
|
|
696
|
+
if (pct !== null && window) {
|
|
697
|
+
const colorKey = pct < 50 ? "success" : pct < 80 ? "warning" : "error";
|
|
698
|
+
const used = fmtTokens(usage?.input ?? 0);
|
|
699
|
+
const max = fmtTokens(window);
|
|
700
|
+
segs.push(
|
|
701
|
+
`${G.context} ${thm.fg("muted", `${used}/${max}`)} ${thm.fg(colorKey, `${Math.round(pct)}%`)}`,
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Cost
|
|
706
|
+
const stats = sessionStats(ctx);
|
|
707
|
+
if (stats.cost > 0) {
|
|
708
|
+
segs.push(`${G.cost} ${thm.fg("success", `$${stats.cost.toFixed(2)}`)}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Duration
|
|
712
|
+
segs.push(
|
|
713
|
+
`${G.clock} ${thm.fg("muted", formatDuration(Date.now() - sessionStartedAt))}`,
|
|
714
|
+
);
|
|
715
|
+
|
|
716
|
+
// Outside a workspace, surface current branch + dirty inline
|
|
717
|
+
if (!workspace && currentBranch) {
|
|
718
|
+
let s = `${G.branch} ${thm.fg("borderAccent", currentBranch)}`;
|
|
719
|
+
if (currentBranchDirty > 0)
|
|
720
|
+
s += ` ${thm.fg("error", `*${currentBranchDirty}`)}`;
|
|
721
|
+
segs.push(s);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return ` ${segs.join(" ")}`;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function renderRepoTable(thm: ThemeApi, maxWidth = 0): string[] {
|
|
728
|
+
if (!workspace) return [];
|
|
729
|
+
|
|
730
|
+
// Visibility filter: PR + non-default branch (toggle with /wsrepos)
|
|
731
|
+
const interesting = repoStates.filter((r) => {
|
|
732
|
+
if (r.loading && !r.branch) return true;
|
|
733
|
+
if (!r.branch) return false;
|
|
734
|
+
if (DEFAULT_BRANCHES.has(r.branch)) return false;
|
|
735
|
+
return !!r.pr;
|
|
736
|
+
});
|
|
737
|
+
const visible = showOnlyInteresting ? interesting : repoStates;
|
|
738
|
+
|
|
739
|
+
// Plain text builders for column width measurement
|
|
740
|
+
const trackingPlain = (r: RepoState): string => {
|
|
741
|
+
const parts: string[] = [];
|
|
742
|
+
if (r.behind > 0) parts.push(`${G.behind}${r.behind}`);
|
|
743
|
+
if (r.ahead > 0) parts.push(`${G.ahead}${r.ahead}`);
|
|
744
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
745
|
+
};
|
|
746
|
+
const branchPlain = (r: RepoState): string =>
|
|
747
|
+
!r.branch ? "—" : `${r.branch}${trackingPlain(r)}`;
|
|
748
|
+
const prPlain = (r: RepoState): string => {
|
|
749
|
+
if (r.loading && !r.pr) return "…";
|
|
750
|
+
if (!r.pr) return r.branch ? "—" : "";
|
|
751
|
+
const stateLabel = r.pr.isDraft
|
|
752
|
+
? "draft"
|
|
753
|
+
: r.pr.state === "OPEN"
|
|
754
|
+
? "open"
|
|
755
|
+
: r.pr.state.toLowerCase();
|
|
756
|
+
const stateIcon = r.pr.isDraft
|
|
757
|
+
? G.prDraft
|
|
758
|
+
: r.pr.state === "MERGED"
|
|
759
|
+
? G.prMerged
|
|
760
|
+
: r.pr.state === "CLOSED"
|
|
761
|
+
? G.prClosed
|
|
762
|
+
: G.prOpen;
|
|
763
|
+
const ci =
|
|
764
|
+
r.pr.ci === "success"
|
|
765
|
+
? ` ${G.ciOk}`
|
|
766
|
+
: r.pr.ci === "failure"
|
|
767
|
+
? ` ${G.ciFail}`
|
|
768
|
+
: r.pr.ci === "pending"
|
|
769
|
+
? ` ${G.ciPending}`
|
|
770
|
+
: "";
|
|
771
|
+
const comments =
|
|
772
|
+
r.pr.commentCount > 0 ? ` ${G.commentIcon} ${r.pr.commentCount}` : "";
|
|
773
|
+
return `${stateIcon} #${r.pr.number} ${stateLabel}${ci}${comments}`;
|
|
774
|
+
};
|
|
775
|
+
const dirtyPlain = (r: RepoState): string =>
|
|
776
|
+
r.dirty > 0 ? `${G.dirty}${r.dirty}` : "";
|
|
777
|
+
|
|
778
|
+
// Headers (same width logic, treated as plain text)
|
|
779
|
+
const headers = { repo: "REPO", branch: "BRANCH", pr: "PR", dirty: "DIRTY" };
|
|
780
|
+
|
|
781
|
+
const repoW = Math.max(
|
|
782
|
+
headers.repo.length,
|
|
783
|
+
...visible.map((r) => r.directoryName.length),
|
|
784
|
+
14,
|
|
785
|
+
);
|
|
786
|
+
const branchW = Math.max(
|
|
787
|
+
headers.branch.length,
|
|
788
|
+
...visible.map((r) => visibleWidth(branchPlain(r))),
|
|
789
|
+
8,
|
|
790
|
+
);
|
|
791
|
+
const prW = Math.max(
|
|
792
|
+
headers.pr.length,
|
|
793
|
+
...visible.map((r) => visibleWidth(prPlain(r))),
|
|
794
|
+
8,
|
|
795
|
+
);
|
|
796
|
+
const dirtyW = Math.max(
|
|
797
|
+
headers.dirty.length,
|
|
798
|
+
...visible.map((r) => dirtyPlain(r).length),
|
|
799
|
+
0,
|
|
800
|
+
);
|
|
801
|
+
|
|
802
|
+
// Header line — using `muted` (not `dim`) so it's actually visible
|
|
803
|
+
const headerLine =
|
|
804
|
+
" " +
|
|
805
|
+
[
|
|
806
|
+
thm.fg("muted", padRightPlain(headers.repo, repoW)),
|
|
807
|
+
thm.fg("muted", padRightPlain(headers.branch, branchW)),
|
|
808
|
+
thm.fg("muted", padRightPlain(headers.pr, prW)),
|
|
809
|
+
thm.fg("muted", padRightPlain(headers.dirty, dirtyW)),
|
|
810
|
+
].join(" ");
|
|
811
|
+
|
|
812
|
+
if (visible.length === 0) {
|
|
813
|
+
return [
|
|
814
|
+
headerLine,
|
|
815
|
+
` ${thm.fg("dim", "no repos with open PRs (use /wsrepos to show all)")}`,
|
|
816
|
+
];
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const rows = visible.map((r) => {
|
|
820
|
+
const repoFullPath = join(workspace!.root, r.directoryName);
|
|
821
|
+
const isCurrent =
|
|
822
|
+
cwd === repoFullPath || cwd.startsWith(`${repoFullPath}/`);
|
|
823
|
+
|
|
824
|
+
// 2-cell active marker
|
|
825
|
+
const marker = isCurrent ? `${thm.fg("accent", G.activeMark)} ` : " ";
|
|
826
|
+
|
|
827
|
+
// REPO column — pad based on plain length, style after
|
|
828
|
+
const repoPlain = truncatePlain(r.directoryName, repoW);
|
|
829
|
+
const repoStyled = isCurrent
|
|
830
|
+
? thm.bold(thm.fg("accent", repoPlain))
|
|
831
|
+
: thm.fg("mdLink", repoPlain);
|
|
832
|
+
const repoCell = repoStyled + " ".repeat(Math.max(0, repoW - repoPlain.length));
|
|
833
|
+
|
|
834
|
+
// BRANCH column
|
|
835
|
+
const branchPlainText = branchPlain(r);
|
|
836
|
+
let branchStyled: string;
|
|
837
|
+
if (!r.branch) {
|
|
838
|
+
branchStyled = thm.fg("dim", "—");
|
|
839
|
+
} else {
|
|
840
|
+
const onDefault = DEFAULT_BRANCHES.has(r.branch);
|
|
841
|
+
const nameStyled = onDefault
|
|
842
|
+
? thm.fg("muted", r.branch)
|
|
843
|
+
: thm.fg("borderAccent", r.branch);
|
|
844
|
+
const linked = r.branchUrl ? osc8(r.branchUrl, nameStyled) : nameStyled;
|
|
845
|
+
const tracking = trackingPlain(r);
|
|
846
|
+
const trackingStyled = tracking ? thm.fg("dim", tracking) : "";
|
|
847
|
+
branchStyled = `${linked}${trackingStyled}`;
|
|
848
|
+
}
|
|
849
|
+
const branchPad = " ".repeat(
|
|
850
|
+
Math.max(0, branchW - visibleWidth(branchPlainText)),
|
|
851
|
+
);
|
|
852
|
+
const branchCell = `${branchStyled}${branchPad}`;
|
|
853
|
+
|
|
854
|
+
// PR column
|
|
855
|
+
const prPlainText = prPlain(r);
|
|
856
|
+
let prStyled: string;
|
|
857
|
+
if (r.loading && !r.pr) {
|
|
858
|
+
prStyled = thm.fg("dim", "…");
|
|
859
|
+
} else if (!r.pr) {
|
|
860
|
+
prStyled = r.branch ? thm.fg("dim", "—") : "";
|
|
861
|
+
} else {
|
|
862
|
+
const { state, isDraft, number, url, ci, commentCount } = r.pr;
|
|
863
|
+
const stateIcon = isDraft
|
|
864
|
+
? G.prDraft
|
|
865
|
+
: state === "MERGED"
|
|
866
|
+
? G.prMerged
|
|
867
|
+
: state === "CLOSED"
|
|
868
|
+
? G.prClosed
|
|
869
|
+
: G.prOpen;
|
|
870
|
+
const stateColor = isDraft
|
|
871
|
+
? "muted"
|
|
872
|
+
: state === "MERGED"
|
|
873
|
+
? "accent"
|
|
874
|
+
: state === "CLOSED"
|
|
875
|
+
? "error"
|
|
876
|
+
: "success";
|
|
877
|
+
const stateLabel = isDraft ? "draft" : state.toLowerCase();
|
|
878
|
+
const head = `${stateIcon} ${thm.fg(stateColor, `#${number} ${stateLabel}`)}`;
|
|
879
|
+
const ciStyled =
|
|
880
|
+
ci === "success"
|
|
881
|
+
? ` ${thm.fg("success", G.ciOk)}`
|
|
882
|
+
: ci === "failure"
|
|
883
|
+
? ` ${thm.fg("error", G.ciFail)}`
|
|
884
|
+
: ci === "pending"
|
|
885
|
+
? ` ${thm.fg("warning", G.ciPending)}`
|
|
886
|
+
: "";
|
|
887
|
+
const commentBadge =
|
|
888
|
+
commentCount > 0
|
|
889
|
+
? ` ${thm.fg("muted", `${G.commentIcon} ${commentCount}`)}`
|
|
890
|
+
: "";
|
|
891
|
+
const inner = `${head}${ciStyled}${commentBadge}`;
|
|
892
|
+
prStyled = osc8(url, inner);
|
|
893
|
+
}
|
|
894
|
+
const prPad = " ".repeat(Math.max(0, prW - visibleWidth(prPlainText)));
|
|
895
|
+
const prCell = `${prStyled}${prPad}`;
|
|
896
|
+
|
|
897
|
+
// DIRTY column
|
|
898
|
+
const dirtyText = dirtyPlain(r);
|
|
899
|
+
const dirtyStyled = r.dirty > 0 ? thm.fg("error", dirtyText) : "";
|
|
900
|
+
const dirtyPad = " ".repeat(Math.max(0, dirtyW - dirtyText.length));
|
|
901
|
+
const dirtyCell = `${dirtyStyled}${dirtyPad}`;
|
|
902
|
+
|
|
903
|
+
return `${marker}${repoCell} ${branchCell} ${prCell} ${dirtyCell}`.trimEnd();
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
return [headerLine, ...rows];
|
|
907
|
+
// Per-row truncation is handled by the widget's render() guard above.
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function padRightPlain(text: string, width: number): string {
|
|
912
|
+
if (text.length >= width) return text;
|
|
913
|
+
return text + " ".repeat(width - text.length);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function truncatePlain(text: string, width: number): string {
|
|
917
|
+
if (text.length <= width) return text;
|
|
918
|
+
return `${text.slice(0, width - 1)}…`;
|
|
919
|
+
}
|