pi-chassis 0.0.0-stage → 0.7.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 +118 -0
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/THIRD_PARTY_NOTICES +30 -0
- package/dist/chassis/alert.d.ts +50 -0
- package/dist/chassis/alert.js +107 -0
- package/dist/chassis/api.d.ts +411 -0
- package/dist/chassis/api.js +676 -0
- package/dist/chassis/bash-output.d.ts +89 -0
- package/dist/chassis/bash-output.js +157 -0
- package/dist/chassis/brand.d.ts +99 -0
- package/dist/chassis/brand.js +79 -0
- package/dist/chassis/client.d.ts +87 -0
- package/dist/chassis/client.js +271 -0
- package/dist/chassis/commands.d.ts +60 -0
- package/dist/chassis/commands.js +211 -0
- package/dist/chassis/editor.d.ts +70 -0
- package/dist/chassis/editor.js +213 -0
- package/dist/chassis/footer.d.ts +14 -0
- package/dist/chassis/footer.js +136 -0
- package/dist/chassis/header.d.ts +12 -0
- package/dist/chassis/header.js +38 -0
- package/dist/chassis/home.d.ts +48 -0
- package/dist/chassis/home.js +184 -0
- package/dist/chassis/icons.d.ts +203 -0
- package/dist/chassis/icons.js +190 -0
- package/dist/chassis/index.d.ts +21 -0
- package/dist/chassis/index.js +582 -0
- package/dist/chassis/layout.d.ts +40 -0
- package/dist/chassis/layout.js +264 -0
- package/dist/chassis/message-modes.d.ts +21 -0
- package/dist/chassis/message-modes.js +49 -0
- package/dist/chassis/messages.d.ts +75 -0
- package/dist/chassis/messages.js +392 -0
- package/dist/chassis/modes.d.ts +21 -0
- package/dist/chassis/modes.js +99 -0
- package/dist/chassis/palette.d.ts +84 -0
- package/dist/chassis/palette.js +235 -0
- package/dist/chassis/peek.d.ts +61 -0
- package/dist/chassis/peek.js +156 -0
- package/dist/chassis/prompt-row.d.ts +42 -0
- package/dist/chassis/prompt-row.js +68 -0
- package/dist/chassis/provider-view.d.ts +34 -0
- package/dist/chassis/provider-view.js +107 -0
- package/dist/chassis/provider.d.ts +69 -0
- package/dist/chassis/provider.js +73 -0
- package/dist/chassis/readonly-command.d.ts +19 -0
- package/dist/chassis/readonly-command.js +454 -0
- package/dist/chassis/sanitize.d.ts +42 -0
- package/dist/chassis/sanitize.js +103 -0
- package/dist/chassis/session-info.d.ts +39 -0
- package/dist/chassis/session-info.js +107 -0
- package/dist/chassis/sidebar.d.ts +48 -0
- package/dist/chassis/sidebar.js +273 -0
- package/dist/chassis/state.d.ts +38 -0
- package/dist/chassis/state.js +19 -0
- package/dist/chassis/telemetry.d.ts +60 -0
- package/dist/chassis/telemetry.js +191 -0
- package/dist/chassis/tools.d.ts +112 -0
- package/dist/chassis/tools.js +588 -0
- package/dist/chassis/version.d.ts +2 -0
- package/dist/chassis/version.js +20 -0
- package/dist/diff/git-diff.d.ts +71 -0
- package/dist/diff/git-diff.js +325 -0
- package/dist/diff/host.d.ts +34 -0
- package/dist/diff/host.js +50 -0
- package/dist/diff/index.d.ts +13 -0
- package/dist/diff/index.js +86 -0
- package/dist/diff/render.d.ts +67 -0
- package/dist/diff/render.js +196 -0
- package/dist/diff/types.d.ts +60 -0
- package/dist/diff/types.js +4 -0
- package/dist/diff/view.d.ts +118 -0
- package/dist/diff/view.js +528 -0
- package/dist/git/flows.d.ts +92 -0
- package/dist/git/flows.js +415 -0
- package/dist/git/forge.d.ts +26 -0
- package/dist/git/forge.js +41 -0
- package/dist/git/index.d.ts +20 -0
- package/dist/git/index.js +121 -0
- package/dist/git/repo.d.ts +64 -0
- package/dist/git/repo.js +186 -0
- package/dist/git/run.d.ts +38 -0
- package/dist/git/run.js +127 -0
- package/dist/git/settings.d.ts +8 -0
- package/dist/git/settings.js +38 -0
- package/dist/git/text.d.ts +51 -0
- package/dist/git/text.js +109 -0
- package/dist/widgets/container/detect.d.ts +23 -0
- package/dist/widgets/container/detect.js +67 -0
- package/dist/widgets/container/index.d.ts +9 -0
- package/dist/widgets/container/index.js +26 -0
- package/dist/widgets/mcp/index.d.ts +13 -0
- package/dist/widgets/mcp/index.js +50 -0
- package/dist/widgets/mcp/status.d.ts +50 -0
- package/dist/widgets/mcp/status.js +70 -0
- package/dist/widgets/session-todos/index.d.ts +12 -0
- package/dist/widgets/session-todos/index.js +52 -0
- package/dist/widgets/subagents/index.d.ts +16 -0
- package/dist/widgets/subagents/index.js +173 -0
- package/dist/widgets/subagents/status.d.ts +61 -0
- package/dist/widgets/subagents/status.js +198 -0
- package/dist/widgets/todo/index.d.ts +13 -0
- package/dist/widgets/todo/index.js +65 -0
- package/dist/widgets/todo/parse.d.ts +29 -0
- package/dist/widgets/todo/parse.js +122 -0
- package/docs/branding.md +25 -0
- package/docs/development.md +22 -0
- package/docs/diff-api.md +18 -0
- package/docs/git.md +23 -0
- package/docs/internals.md +13 -0
- package/docs/provider-status.md +23 -0
- package/docs/status-widgets.md +157 -0
- package/docs/tool-rows.md +50 -0
- package/docs/usage.md +62 -0
- package/docs/widgets.md +113 -0
- package/extensions/chassis/alert.ts +127 -0
- package/extensions/chassis/api.ts +967 -0
- package/extensions/chassis/bash-output.ts +186 -0
- package/extensions/chassis/brand.ts +151 -0
- package/extensions/chassis/client.ts +284 -0
- package/extensions/chassis/commands.ts +244 -0
- package/extensions/chassis/editor.ts +253 -0
- package/extensions/chassis/footer.ts +131 -0
- package/extensions/chassis/header.ts +43 -0
- package/extensions/chassis/home.ts +186 -0
- package/extensions/chassis/icons.ts +220 -0
- package/extensions/chassis/index.ts +605 -0
- package/extensions/chassis/layout.ts +298 -0
- package/extensions/chassis/message-modes.ts +48 -0
- package/extensions/chassis/messages.ts +403 -0
- package/extensions/chassis/modes.ts +108 -0
- package/extensions/chassis/palette.ts +258 -0
- package/extensions/chassis/peek.ts +167 -0
- package/extensions/chassis/prompt-row.ts +86 -0
- package/extensions/chassis/provider-view.ts +118 -0
- package/extensions/chassis/provider.ts +115 -0
- package/extensions/chassis/readonly-command.ts +440 -0
- package/extensions/chassis/sanitize.ts +108 -0
- package/extensions/chassis/session-info.ts +127 -0
- package/extensions/chassis/sidebar.ts +285 -0
- package/extensions/chassis/state.ts +50 -0
- package/extensions/chassis/telemetry.ts +222 -0
- package/extensions/chassis/tools.ts +618 -0
- package/extensions/chassis/version.ts +20 -0
- package/extensions/diff/git-diff.ts +365 -0
- package/extensions/diff/host.ts +58 -0
- package/extensions/diff/index.ts +101 -0
- package/extensions/diff/render.ts +217 -0
- package/extensions/diff/types.ts +67 -0
- package/extensions/diff/view.ts +523 -0
- package/extensions/git/flows.ts +485 -0
- package/extensions/git/forge.ts +54 -0
- package/extensions/git/index.ts +117 -0
- package/extensions/git/repo.ts +200 -0
- package/extensions/git/run.ts +143 -0
- package/extensions/git/settings.ts +38 -0
- package/extensions/git/text.ts +133 -0
- package/extensions/widgets/container/detect.ts +81 -0
- package/extensions/widgets/container/index.ts +29 -0
- package/extensions/widgets/mcp/index.ts +53 -0
- package/extensions/widgets/mcp/status.ts +94 -0
- package/extensions/widgets/session-todos/index.ts +55 -0
- package/extensions/widgets/subagents/index.ts +170 -0
- package/extensions/widgets/subagents/status.ts +237 -0
- package/extensions/widgets/todo/index.ts +66 -0
- package/extensions/widgets/todo/parse.ts +119 -0
- package/package.json +91 -4
- package/scripts/link-pi.mjs +76 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode-style prompt editor.
|
|
3
|
+
*
|
|
4
|
+
* ┃ ask anything…
|
|
5
|
+
* ┃
|
|
6
|
+
* ┃ BUILD Opus 5.5 Anthropic medium
|
|
7
|
+
* tab mode · ctrl+p commands · ctrl+x leader
|
|
8
|
+
*
|
|
9
|
+
* A shaded panel with a heavy left rail in the mode colour (the brand primary for build,
|
|
10
|
+
* the brand plan colour for plan) and the mode pill and model inside, like OpenCode's prompt; key hints sit under it.
|
|
11
|
+
* pi's working status (spinner, retry countdown, compaction) is shown in the footer instead.
|
|
12
|
+
*
|
|
13
|
+
* Keys handled here before pi sees them:
|
|
14
|
+
* - Tab on an empty prompt (and no autocomplete open): switch Build/Plan mode. Shift+Tab is left to
|
|
15
|
+
* pi, which cycles the thinking level with it.
|
|
16
|
+
* - Ctrl+X: leader key; the next key runs a leader action (see commands.ts)
|
|
17
|
+
* - Ctrl+P: command palette. pi binds ctrl+p to model cycling, a reserved action that
|
|
18
|
+
* registerShortcut() can't take over, so the editor intercepts it (model cycling stays on
|
|
19
|
+
* ctrl+x m / ctrl+l).
|
|
20
|
+
* Everything else goes to pi's CustomEditor, so app keybindings keep working.
|
|
21
|
+
*/
|
|
22
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
24
|
+
import { paint, shade, surfaceColor } from "./palette.js";
|
|
25
|
+
import { modeColorValue, promptRow } from "./prompt-row.js";
|
|
26
|
+
const RAIL = "┃";
|
|
27
|
+
export class PromptEditor extends CustomEditor {
|
|
28
|
+
leaderPending = false;
|
|
29
|
+
leaderTimer;
|
|
30
|
+
hooks;
|
|
31
|
+
constructor(tui, editorTheme, keybindings, hooks) {
|
|
32
|
+
super(tui, editorTheme, keybindings, { paddingX: 0, embedWorkingStatus: true });
|
|
33
|
+
this.hooks = hooks;
|
|
34
|
+
}
|
|
35
|
+
setPaddingX(_padding) {
|
|
36
|
+
super.setPaddingX(0);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* pi passes its working/retry/compaction indicator here (embedWorkingStatus). Keep it in shared
|
|
40
|
+
* state for the footer instead of drawing it in the editor's top border.
|
|
41
|
+
*/
|
|
42
|
+
setWorkingStatusIndicator(indicator) {
|
|
43
|
+
super.setWorkingStatusIndicator(undefined);
|
|
44
|
+
this.hooks.state.statusIndicator = indicator;
|
|
45
|
+
this.hooks.onStatusChange?.();
|
|
46
|
+
}
|
|
47
|
+
clearLeader() {
|
|
48
|
+
if (this.leaderTimer)
|
|
49
|
+
clearTimeout(this.leaderTimer);
|
|
50
|
+
this.leaderTimer = undefined;
|
|
51
|
+
if (this.leaderPending) {
|
|
52
|
+
this.leaderPending = false;
|
|
53
|
+
this.hooks.onLeaderState(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Cursor blink: off phase hides the editor's reverse-video cursor; a keypress resets it. */
|
|
57
|
+
blinkOff = false;
|
|
58
|
+
blinkTimer;
|
|
59
|
+
static BLINK_MS = 530;
|
|
60
|
+
syncBlink() {
|
|
61
|
+
const want = this.hooks.blink?.() ?? false;
|
|
62
|
+
if (want && !this.blinkTimer) {
|
|
63
|
+
this.blinkTimer = setInterval(() => {
|
|
64
|
+
if (!(this.hooks.blink?.() ?? false)) {
|
|
65
|
+
this.stopBlink();
|
|
66
|
+
this.tui.requestRender();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
this.blinkOff = !this.blinkOff;
|
|
70
|
+
this.tui.requestRender();
|
|
71
|
+
}, PromptEditor.BLINK_MS);
|
|
72
|
+
this.blinkTimer.unref?.();
|
|
73
|
+
}
|
|
74
|
+
else if (!want && this.blinkTimer) {
|
|
75
|
+
this.stopBlink();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stopBlink() {
|
|
79
|
+
if (this.blinkTimer)
|
|
80
|
+
clearInterval(this.blinkTimer);
|
|
81
|
+
this.blinkTimer = undefined;
|
|
82
|
+
this.blinkOff = false;
|
|
83
|
+
}
|
|
84
|
+
dispose() {
|
|
85
|
+
this.stopBlink();
|
|
86
|
+
}
|
|
87
|
+
handleInput(data) {
|
|
88
|
+
// Typing keeps the cursor visible; the blink restarts from "on".
|
|
89
|
+
if (this.blinkTimer) {
|
|
90
|
+
this.stopBlink();
|
|
91
|
+
}
|
|
92
|
+
if (this.leaderPending) {
|
|
93
|
+
this.clearLeader();
|
|
94
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+x")) {
|
|
95
|
+
this.tui.requestRender();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (this.hooks.onLeaderKey(data)) {
|
|
99
|
+
this.tui.requestRender();
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
// Unknown leader key: fall through as a normal key.
|
|
103
|
+
}
|
|
104
|
+
if (matchesKey(data, "ctrl+p")) {
|
|
105
|
+
this.hooks.onPalette();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (matchesKey(data, "ctrl+x")) {
|
|
109
|
+
this.leaderPending = true;
|
|
110
|
+
this.hooks.onLeaderState(true);
|
|
111
|
+
this.leaderTimer = setTimeout(() => {
|
|
112
|
+
this.clearLeader();
|
|
113
|
+
this.tui.requestRender();
|
|
114
|
+
}, this.hooks.leaderTimeoutMs);
|
|
115
|
+
this.leaderTimer.unref?.();
|
|
116
|
+
this.tui.requestRender();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const idlePrompt = this.getText().length === 0 && !this.isShowingAutocomplete();
|
|
120
|
+
if (idlePrompt && matchesKey(data, "tab")) {
|
|
121
|
+
this.hooks.onModeCycle();
|
|
122
|
+
this.tui.requestRender();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
super.handleInput(data);
|
|
126
|
+
}
|
|
127
|
+
modeColor(s) {
|
|
128
|
+
const theme = this.hooks.theme();
|
|
129
|
+
// The rail is a shape, not text: it keeps the exact brand colour under any theme.
|
|
130
|
+
return paint(theme, modeColorValue(this.hooks.state.mode === "plan" ? "plan" : "build"), s);
|
|
131
|
+
}
|
|
132
|
+
render(width) {
|
|
133
|
+
const theme = this.hooks.theme();
|
|
134
|
+
if (width < 12)
|
|
135
|
+
return super.render(width);
|
|
136
|
+
// OpenCode prompt (component/prompt/index.tsx): a ┃ rail in the agent colour, then a box on
|
|
137
|
+
// the element background with 1 row of top padding and 2 columns either side, the text, a
|
|
138
|
+
// blank row, the agent + model row, and a half-block bottom edge (╹▀▀▀). Key hints sit
|
|
139
|
+
// below, outside the box.
|
|
140
|
+
// ┃
|
|
141
|
+
// ┃ prompt text…
|
|
142
|
+
// ┃
|
|
143
|
+
// ┃ BUILD Opus 5.5 Anthropic medium
|
|
144
|
+
// ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
|
145
|
+
// tab agents ctrl+p commands
|
|
146
|
+
// pi's working spinner goes to the footer (see setWorkingStatusIndicator).
|
|
147
|
+
const PAD = 2;
|
|
148
|
+
const boxW = width - 1; // after the rail
|
|
149
|
+
const inner = boxW - PAD * 2;
|
|
150
|
+
const base = super.render(inner);
|
|
151
|
+
const bottomIdx = bottomBorderIndex(this, base);
|
|
152
|
+
const rail = this.modeColor("┃");
|
|
153
|
+
const row = (content) => `${rail}${shade(theme, `${" ".repeat(PAD)}${content}`, boxW, "element")}`;
|
|
154
|
+
const out = [];
|
|
155
|
+
// Top padding row; a "↑ N more" scroll hint from the base top border goes there.
|
|
156
|
+
out.push(row(stripRule(base[0] ?? "", inner)));
|
|
157
|
+
this.syncBlink();
|
|
158
|
+
const hideCursor = this.blinkOff;
|
|
159
|
+
const placeholder = this.getText().length === 0 ? this.hooks.placeholder?.() : undefined;
|
|
160
|
+
for (let i = 1; i < bottomIdx; i++) {
|
|
161
|
+
let line = base[i] ?? "";
|
|
162
|
+
// The base editor draws its cursor as reverse video; drop that during the off phase.
|
|
163
|
+
if (hideCursor)
|
|
164
|
+
line = line.replace(/\x1b\[7m/g, "");
|
|
165
|
+
if (placeholder && i === 1)
|
|
166
|
+
line = `${line.replace(/ +$/, "")}${theme.fg("muted", placeholder)}`;
|
|
167
|
+
out.push(row(truncateToWidth(line, inner, "…")));
|
|
168
|
+
}
|
|
169
|
+
// Blank row, or the "↓ N more" scroll hint from the base bottom border.
|
|
170
|
+
const more = (base[bottomIdx] ?? "").replace(/\x1b\[[0-9;]*m/g, "").match(/[↓]\s+\d+\s+more/);
|
|
171
|
+
out.push(row(more ? theme.fg("dim", more[0]) : ""));
|
|
172
|
+
// Agent + model row: a mode pill, the model, the provider and the thinking level (prompt-row.ts).
|
|
173
|
+
out.push(row(promptRow(theme, this.hooks.state.mode === "plan" ? "plan" : "build", this.hooks.modelParts(), inner)));
|
|
174
|
+
// Bottom edge: ╹ in the rail colour, then upper half blocks in the box colour.
|
|
175
|
+
out.push(`${this.modeColor("╹")}${paint(theme, surfaceColor(theme, "element"), "▀".repeat(boxW))}`);
|
|
176
|
+
// Key hints under the box, right-aligned; an optional brand line on the left.
|
|
177
|
+
const key = (k, d) => `${theme.fg("text", k)} ${theme.fg("muted", d)}`;
|
|
178
|
+
const hint = this.leaderPending
|
|
179
|
+
? theme.fg("warning", "ctrl+x … n new · m model · b sidebar · ? help")
|
|
180
|
+
: [key("tab", "agents"), key("ctrl+p", "commands"), key("ctrl+x", "leader")].join(" ");
|
|
181
|
+
const hw = visibleWidth(hint);
|
|
182
|
+
const left = this.hooks.hintLeft?.();
|
|
183
|
+
const lw = left ? visibleWidth(left) + 1 : 0; // 1 column: in line with the rail
|
|
184
|
+
if (left && lw + hw + 4 < width)
|
|
185
|
+
out.push(` ${left}${" ".repeat(width - lw - hw - 2)}${hint} `);
|
|
186
|
+
else
|
|
187
|
+
out.push(hw + 2 < width ? `${" ".repeat(width - hw - 2)}${hint} ` : truncateToWidth(hint, width, "…"));
|
|
188
|
+
// Autocomplete list below.
|
|
189
|
+
for (let i = bottomIdx + 1; i < base.length; i++)
|
|
190
|
+
out.push(` ${base[i]}`);
|
|
191
|
+
return out.map((l) => truncateToWidth(l, width, ""));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Index of the base editor's bottom border: it renders the top border, then
|
|
196
|
+
* `renderedVisibleLineCount` text lines (a pi-tui Editor field, read at runtime), then the bottom
|
|
197
|
+
* border, then any autocomplete lines.
|
|
198
|
+
*/
|
|
199
|
+
function bottomBorderIndex(editor, lines) {
|
|
200
|
+
const n = editor.renderedVisibleLineCount;
|
|
201
|
+
if (typeof n === "number" && n >= 0 && n + 1 < lines.length)
|
|
202
|
+
return n + 1;
|
|
203
|
+
return lines.length - 1;
|
|
204
|
+
}
|
|
205
|
+
/** Replace a plain ─ rule with blank space; keep an embedded status (spinner text) visible. */
|
|
206
|
+
function stripRule(line, width) {
|
|
207
|
+
const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
|
|
208
|
+
if (/^─*$/.test(plain))
|
|
209
|
+
return " ".repeat(width);
|
|
210
|
+
// Working status or scroll hint embedded in the border: drop the dashes around it.
|
|
211
|
+
const text = line.replace(/─/g, " ").replace(/^\s+/, "");
|
|
212
|
+
return truncateToWidth(text, width, "");
|
|
213
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer: one line under the prompt, OpenCode style.
|
|
3
|
+
*
|
|
4
|
+
* ⠋ Working 4s
|
|
5
|
+
*
|
|
6
|
+
* pi's working indicator. The cwd, git branch, context, cost, status widgets and other extensions'
|
|
7
|
+
* setStatus() strings (MCP, LSP, provider usage, ...) live in the sidebar; they only show here
|
|
8
|
+
* while the sidebar is hidden (ctrl+x b, a narrow terminal, or the overlay fallback).
|
|
9
|
+
* Segments drop from the left of the right group when the line is too narrow.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import type { UiState } from "./state.ts";
|
|
13
|
+
export declare function renderFooter(state: UiState, theme: Theme, data: ReadonlyFooterDataProvider, width: number): string[];
|
|
14
|
+
export declare function installFooter(ctx: ExtensionContext, state: UiState, onRequestRender: (fn: (() => void) | undefined) => void): () => void;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Footer: one line under the prompt, OpenCode style.
|
|
3
|
+
*
|
|
4
|
+
* ⠋ Working 4s
|
|
5
|
+
*
|
|
6
|
+
* pi's working indicator. The cwd, git branch, context, cost, status widgets and other extensions'
|
|
7
|
+
* setStatus() strings (MCP, LSP, provider usage, ...) live in the sidebar; they only show here
|
|
8
|
+
* while the sidebar is hidden (ctrl+x b, a narrow terminal, or the overlay fallback).
|
|
9
|
+
* Segments drop from the left of the right group when the line is too narrow.
|
|
10
|
+
*/
|
|
11
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { claimedStatusKeys, renderWidget, widgetsFor } from "./api.js";
|
|
13
|
+
import { activeProviderStatus, claimedProviderKeys, providerDisplayName, providerFooterText } from "./provider-view.js";
|
|
14
|
+
import { fmtDuration, fmtTokens, hasOwnColor, homeRelative, sanitizeKeepColor, widgetPaint } from "./palette.js";
|
|
15
|
+
import { usageTotals } from "./session-info.js";
|
|
16
|
+
import { leadIcon, resolveIcon } from "./icons.js";
|
|
17
|
+
import { branchGlyph, worktreeTag } from "./sidebar.js";
|
|
18
|
+
export function renderFooter(state, theme, data, width) {
|
|
19
|
+
// The home screen has its own bottom bar (home.ts).
|
|
20
|
+
if (state.home())
|
|
21
|
+
return [];
|
|
22
|
+
const ctx = state.ctx;
|
|
23
|
+
const sep = theme.fg("borderMuted", " │ ");
|
|
24
|
+
// Left: cwd and git only while the sidebar (which shows them) is hidden, then the indicator.
|
|
25
|
+
const sidebar = state.sidebarShown();
|
|
26
|
+
const left = sidebar ? [] : [theme.fg("muted", homeRelative(ctx?.cwd ?? process.cwd()))];
|
|
27
|
+
const branch = sidebar ? undefined : (state.git?.branch ?? data.getGitBranch() ?? undefined);
|
|
28
|
+
if (branch) {
|
|
29
|
+
let g = `${leadIcon(branchGlyph(), (s) => theme.fg("accent", s))}${theme.fg("text", branch)}`;
|
|
30
|
+
if (state.git?.dirty)
|
|
31
|
+
g += theme.fg("warning", ` ●${state.git.dirty}`);
|
|
32
|
+
if (state.git?.ahead)
|
|
33
|
+
g += theme.fg("dim", ` ↑${state.git.ahead}`);
|
|
34
|
+
if (state.git?.behind)
|
|
35
|
+
g += theme.fg("dim", ` ↓${state.git.behind}`);
|
|
36
|
+
g += worktreeTag(theme, state.git);
|
|
37
|
+
left.push(g);
|
|
38
|
+
}
|
|
39
|
+
// pi's status indicator (spinner + "Working…", retry countdown, compaction) with elapsed time.
|
|
40
|
+
const indicator = state.statusIndicator;
|
|
41
|
+
if (indicator) {
|
|
42
|
+
const elapsed = state.workingSince ? theme.fg("dim", ` ${fmtDuration(Date.now() - state.workingSince)}`) : "";
|
|
43
|
+
let status = "";
|
|
44
|
+
try {
|
|
45
|
+
status = indicator.renderInBorder(40);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
status = "";
|
|
49
|
+
}
|
|
50
|
+
left.push(`${status}${elapsed}`);
|
|
51
|
+
}
|
|
52
|
+
else if (state.workingSince) {
|
|
53
|
+
left.push(theme.fg("dim", `working ${fmtDuration(Date.now() - state.workingSince)}`));
|
|
54
|
+
}
|
|
55
|
+
else if (state.lastDurationMs !== undefined) {
|
|
56
|
+
left.push(theme.fg("dim", `done ${fmtDuration(state.lastDurationMs)}`));
|
|
57
|
+
}
|
|
58
|
+
// Right (sidebar hidden only): statuses, then context and cost.
|
|
59
|
+
const right = [];
|
|
60
|
+
const showStatuses = !state.sidebarShown();
|
|
61
|
+
for (const w of showStatuses ? widgetsFor("footer") : []) {
|
|
62
|
+
const view = renderWidget(w, { theme, ctx, width });
|
|
63
|
+
const viewIcon = resolveIcon(view?.icon);
|
|
64
|
+
if (!view?.text && !viewIcon)
|
|
65
|
+
continue;
|
|
66
|
+
const text = [viewIcon, view.label, view.text].filter(Boolean).join(" ");
|
|
67
|
+
right.push(widgetPaint(theme, view.color, view.level, text));
|
|
68
|
+
}
|
|
69
|
+
const claimed = new Set([...claimedStatusKeys(), ...claimedProviderKeys(ctx, data.getExtensionStatuses())]);
|
|
70
|
+
const statuses = [...(showStatuses ? data.getExtensionStatuses() : new Map())]
|
|
71
|
+
.filter(([k]) => !claimed.has(k))
|
|
72
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
73
|
+
for (const [, raw] of statuses) {
|
|
74
|
+
const s = sanitizeKeepColor(raw);
|
|
75
|
+
if (!visibleWidth(s))
|
|
76
|
+
continue;
|
|
77
|
+
right.push(hasOwnColor(s) ? s : theme.fg("muted", s));
|
|
78
|
+
}
|
|
79
|
+
// Context, cost and provider usage are in the sidebar's Context section; with the sidebar hidden
|
|
80
|
+
// they come back here.
|
|
81
|
+
if (ctx && showStatuses) {
|
|
82
|
+
try {
|
|
83
|
+
const cu = ctx.getContextUsage();
|
|
84
|
+
const usage = usageTotals(ctx);
|
|
85
|
+
const parts = [];
|
|
86
|
+
if (cu?.tokens != null)
|
|
87
|
+
parts.push(fmtTokens(cu.tokens));
|
|
88
|
+
if (cu?.percent != null) {
|
|
89
|
+
const c = cu.percent >= 85 ? "error" : cu.percent >= 60 ? "warning" : "muted";
|
|
90
|
+
parts.push(theme.fg(c, `${cu.percent.toFixed(1)}%`));
|
|
91
|
+
}
|
|
92
|
+
parts.push(`$${usage.cost.toFixed(2)}`);
|
|
93
|
+
const spec = activeProviderStatus(ctx, data.getExtensionStatuses());
|
|
94
|
+
const extra = spec ? providerFooterText(spec, ctx, data.getExtensionStatuses()) : "";
|
|
95
|
+
const tag = spec ? providerDisplayName(ctx, spec).toLowerCase() : "";
|
|
96
|
+
right.push(theme.fg("muted", parts.join(" ")) + (visibleWidth(extra) ? ` ${theme.fg("dim", tag)} ${extra}` : ""));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// stale ctx
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const leftStr = ` ${left.join(" ")}`;
|
|
103
|
+
// Drop right-hand segments from the front until it fits next to the left part.
|
|
104
|
+
let segs = right;
|
|
105
|
+
const fits = () => visibleWidth(leftStr) + visibleWidth(segs.join(sep)) + 3 <= width;
|
|
106
|
+
while (segs.length > 1 && !fits())
|
|
107
|
+
segs = segs.slice(1);
|
|
108
|
+
const rightStr = segs.join(sep);
|
|
109
|
+
const gap = width - visibleWidth(leftStr) - visibleWidth(rightStr) - 1;
|
|
110
|
+
if (gap >= 1)
|
|
111
|
+
return [`${leftStr}${" ".repeat(gap)}${rightStr} `];
|
|
112
|
+
return [truncateToWidth(leftStr, width, "…"), truncateToWidth(` ${rightStr}`, width, "…")];
|
|
113
|
+
}
|
|
114
|
+
export function installFooter(ctx, state, onRequestRender) {
|
|
115
|
+
ctx.ui.setFooter((tui, theme, data) => {
|
|
116
|
+
onRequestRender(() => tui.requestRender());
|
|
117
|
+
state.statuses = () => data.getExtensionStatuses();
|
|
118
|
+
const unsub = data.onBranchChange(() => tui.requestRender());
|
|
119
|
+
return {
|
|
120
|
+
dispose() {
|
|
121
|
+
unsub();
|
|
122
|
+
onRequestRender(undefined);
|
|
123
|
+
},
|
|
124
|
+
invalidate() { },
|
|
125
|
+
render: (width) => renderFooter(state, theme, data, width),
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
return () => {
|
|
129
|
+
try {
|
|
130
|
+
ctx.ui.setFooter(undefined);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// stale ctx
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup header: the brand's art (the pi logo unless a brand supplies its own), centred, with
|
|
3
|
+
* the tagline if the brand has one, the model, cwd and a few OpenCode-style key hints. Shown once
|
|
4
|
+
* at the top of the transcript.
|
|
5
|
+
*/
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
export interface HeaderInfo {
|
|
8
|
+
model: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
version: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function renderHeader(theme: Theme, width: number, info: HeaderInfo): string[];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup header: the brand's art (the pi logo unless a brand supplies its own), centred, with
|
|
3
|
+
* the tagline if the brand has one, the model, cwd and a few OpenCode-style key hints. Shown once
|
|
4
|
+
* at the top of the transcript.
|
|
5
|
+
*/
|
|
6
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { getBrand } from "./brand.js";
|
|
8
|
+
import { piLogoRows } from "./home.js";
|
|
9
|
+
import { safeGlyph } from "./icons.js";
|
|
10
|
+
import { homeRelative, neutral, primary } from "./palette.js";
|
|
11
|
+
const center = (s, w) => " ".repeat(Math.max(0, Math.floor((w - visibleWidth(s)) / 2))) + s;
|
|
12
|
+
export function renderHeader(theme, width, info) {
|
|
13
|
+
const brand = getBrand();
|
|
14
|
+
if (width < 30)
|
|
15
|
+
return [primary(theme, safeGlyph(brand.glyph)) + theme.bold(" pi")];
|
|
16
|
+
const lines = [""];
|
|
17
|
+
if (brand.art) {
|
|
18
|
+
const artW = Math.max(...brand.art.map((r) => visibleWidth(r)));
|
|
19
|
+
for (const row of brand.art)
|
|
20
|
+
lines.push(center(primary(theme, row + " ".repeat(artW - visibleWidth(row))), width));
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
for (const row of piLogoRows(theme))
|
|
24
|
+
lines.push(center(row, width));
|
|
25
|
+
}
|
|
26
|
+
lines.push("");
|
|
27
|
+
if (brand.tagline)
|
|
28
|
+
lines.push(center(theme.bold(neutral(theme, brand.tagline)), width));
|
|
29
|
+
lines.push(center(theme.fg("muted", info.model), width));
|
|
30
|
+
lines.push(center(theme.fg("dim", homeRelative(info.cwd)), width));
|
|
31
|
+
lines.push("");
|
|
32
|
+
const key = (k, d) => `${theme.fg("text", k)} ${theme.fg("dim", d)}`;
|
|
33
|
+
const hints = [key("tab", "build/plan"), key("ctrl+p", "commands"), key("ctrl+x b", "sidebar"), key("ctrl+x ?", "keys")];
|
|
34
|
+
lines.push(center(hints.join(theme.fg("dim", " ")), width));
|
|
35
|
+
lines.push(center(theme.fg("dim", `pi v${info.version}`), width));
|
|
36
|
+
lines.push("");
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home screen, OpenCode style (packages/tui/src/routes/home.tsx): before the session has a
|
|
3
|
+
* message, the transcript and sidebar give way to pi's own logo, the prompt (max 75 columns), a
|
|
4
|
+
* tip, and a bottom bar with the directory, MCP count and version. layout.ts composes these with
|
|
5
|
+
* pi's own prompt; this file renders the pieces.
|
|
6
|
+
*
|
|
7
|
+
* (pi logo, coral/blue/yellow)
|
|
8
|
+
*
|
|
9
|
+
* ┃ Ask anything… "Fix a TODO in the codebase"
|
|
10
|
+
* ┃ BUILD Opus 5.5 Anthropic medium
|
|
11
|
+
* ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
|
12
|
+
* tab agents ctrl+p commands
|
|
13
|
+
*
|
|
14
|
+
* ● Tip Press tab to cycle between Build and Plan agents
|
|
15
|
+
*
|
|
16
|
+
* ● Acme pi 1.1.0 · chassis 0.1.0 ~/git/pi-chassis main ● 6 MCP ⟲ "chassis" 2h ago ctrl+x r
|
|
17
|
+
*/
|
|
18
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { UiState } from "./state.ts";
|
|
20
|
+
/** OpenCode's prompt width on the home screen (tui.prompt.max_width default). */
|
|
21
|
+
export declare const HOME_PROMPT_WIDTH = 75;
|
|
22
|
+
/** OpenCode's home placeholders, shown as `Ask anything… "<one of these>"`. */
|
|
23
|
+
export declare const PLACEHOLDERS: string[];
|
|
24
|
+
/** The pi logo's pixels (pi's own components/pi-logo-animation.js): c coral, b blue, y yellow. */
|
|
25
|
+
export declare const PI_PIXELS: string[];
|
|
26
|
+
/**
|
|
27
|
+
* The pi logo scaled up: each logo pixel becomes `scale` columns by `scale` half-rows, drawn with
|
|
28
|
+
* half blocks, so scale 3 gives 12 columns by 6 rows (a 12 by 6 cell logo).
|
|
29
|
+
*/
|
|
30
|
+
export declare function piLogoRows(theme: Theme, scale?: number): string[];
|
|
31
|
+
/** The logo, centred: pi's own logo (falls back to a plain "pi" when too narrow). */
|
|
32
|
+
export declare function renderLogo(theme: Theme, width: number): string[];
|
|
33
|
+
/** Tips, `{x}` marks the highlighted part (OpenCode: {highlight}…{/highlight}). */
|
|
34
|
+
export declare const TIPS: string[];
|
|
35
|
+
/** Splits a tip into plain and highlighted parts. */
|
|
36
|
+
export declare function parseTip(tip: string): {
|
|
37
|
+
text: string;
|
|
38
|
+
highlight: boolean;
|
|
39
|
+
}[];
|
|
40
|
+
/** "● Tip …" (OpenCode: warning-coloured label, highlights in text, the rest muted), centred. */
|
|
41
|
+
export declare function renderTip(theme: Theme, width: number, index: number): string[];
|
|
42
|
+
/**
|
|
43
|
+
* Bottom bar (OpenCode home/footer.tsx): 1 row of padding above and below, 2 columns at the sides;
|
|
44
|
+
* the folder and branch (with the branch icon) muted and the MCP count with a coloured dot on the left; on the right, the most
|
|
45
|
+
* recent other session in this directory and the leader key that resumes it. With `piVersion`, the
|
|
46
|
+
* brand with the pi and chassis versions comes first on the same line.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderHomeFooter(state: UiState, theme: Theme, width: number, piVersion?: string): string[];
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home screen, OpenCode style (packages/tui/src/routes/home.tsx): before the session has a
|
|
3
|
+
* message, the transcript and sidebar give way to pi's own logo, the prompt (max 75 columns), a
|
|
4
|
+
* tip, and a bottom bar with the directory, MCP count and version. layout.ts composes these with
|
|
5
|
+
* pi's own prompt; this file renders the pieces.
|
|
6
|
+
*
|
|
7
|
+
* (pi logo, coral/blue/yellow)
|
|
8
|
+
*
|
|
9
|
+
* ┃ Ask anything… "Fix a TODO in the codebase"
|
|
10
|
+
* ┃ BUILD Opus 5.5 Anthropic medium
|
|
11
|
+
* ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
|
12
|
+
* tab agents ctrl+p commands
|
|
13
|
+
*
|
|
14
|
+
* ● Tip Press tab to cycle between Build and Plan agents
|
|
15
|
+
*
|
|
16
|
+
* ● Acme pi 1.1.0 · chassis 0.1.0 ~/git/pi-chassis main ● 6 MCP ⟲ "chassis" 2h ago ctrl+x r
|
|
17
|
+
*/
|
|
18
|
+
import { backgroundAnsi, foregroundAnsi, rgbColor, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
19
|
+
import { widgetsFor } from "./api.js";
|
|
20
|
+
import { brandLine, fmtAgo, homeRelative, sanitize } from "./palette.js";
|
|
21
|
+
import { icon, leadIcon } from "./icons.js";
|
|
22
|
+
import { branchGlyph, worktreeTag } from "./sidebar.js";
|
|
23
|
+
import { chassisVersion } from "./version.js";
|
|
24
|
+
/** OpenCode's prompt width on the home screen (tui.prompt.max_width default). */
|
|
25
|
+
export const HOME_PROMPT_WIDTH = 75;
|
|
26
|
+
/** OpenCode's home placeholders, shown as `Ask anything… "<one of these>"`. */
|
|
27
|
+
export const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"];
|
|
28
|
+
const center = (s, w) => {
|
|
29
|
+
const pad = Math.max(0, Math.floor((w - visibleWidth(s)) / 2));
|
|
30
|
+
return truncateToWidth(" ".repeat(pad) + s, w, "");
|
|
31
|
+
};
|
|
32
|
+
/** The pi logo's pixels (pi's own components/pi-logo-animation.js): c coral, b blue, y yellow. */
|
|
33
|
+
export const PI_PIXELS = ["ccc.", "b.c.", "bb.y", "b..y"];
|
|
34
|
+
const PI_COLORS = {
|
|
35
|
+
c: rgbColor(228, 138, 122),
|
|
36
|
+
b: rgbColor(79, 142, 179),
|
|
37
|
+
y: rgbColor(234, 182, 93),
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* The pi logo scaled up: each logo pixel becomes `scale` columns by `scale` half-rows, drawn with
|
|
41
|
+
* half blocks, so scale 3 gives 12 columns by 6 rows (a 12 by 6 cell logo).
|
|
42
|
+
*/
|
|
43
|
+
export function piLogoRows(theme, scale = 3) {
|
|
44
|
+
const mode = theme.getColorMode();
|
|
45
|
+
const grid = [];
|
|
46
|
+
for (const row of PI_PIXELS) {
|
|
47
|
+
const cells = [...row].flatMap((p) => Array(scale).fill(PI_COLORS[p] ? p : undefined));
|
|
48
|
+
for (let k = 0; k < scale; k++)
|
|
49
|
+
grid.push(cells);
|
|
50
|
+
}
|
|
51
|
+
const fg = (p) => foregroundAnsi(PI_COLORS[p], mode);
|
|
52
|
+
const bg = (p) => backgroundAnsi(PI_COLORS[p], mode);
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (let y = 0; y < grid.length; y += 2) {
|
|
55
|
+
const top = grid[y];
|
|
56
|
+
const bottom = grid[y + 1] ?? [];
|
|
57
|
+
let line = "";
|
|
58
|
+
for (let x = 0; x < top.length; x++) {
|
|
59
|
+
const t = top[x];
|
|
60
|
+
const b = bottom[x];
|
|
61
|
+
if (!t && !b)
|
|
62
|
+
line += " ";
|
|
63
|
+
else if (t && b && t === b)
|
|
64
|
+
line += `${fg(t)}█\x1b[39m`;
|
|
65
|
+
else if (t && b)
|
|
66
|
+
line += `${fg(t)}${bg(b)}▀\x1b[39;49m`;
|
|
67
|
+
else if (t)
|
|
68
|
+
line += `${fg(t)}▀\x1b[39m`;
|
|
69
|
+
else
|
|
70
|
+
line += `${fg(b)}▄\x1b[39m`;
|
|
71
|
+
}
|
|
72
|
+
lines.push(line);
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}
|
|
76
|
+
/** The logo, centred: pi's own logo (falls back to a plain "pi" when too narrow). */
|
|
77
|
+
export function renderLogo(theme, width) {
|
|
78
|
+
const pi = piLogoRows(theme);
|
|
79
|
+
if (width < 12)
|
|
80
|
+
return [center(theme.bold(theme.fg("text", "pi")), width)];
|
|
81
|
+
return pi.map((l) => center(l, width));
|
|
82
|
+
}
|
|
83
|
+
/** Tips, `{x}` marks the highlighted part (OpenCode: {highlight}…{/highlight}). */
|
|
84
|
+
export const TIPS = [
|
|
85
|
+
"Press {tab} to cycle between Build and Plan agents",
|
|
86
|
+
"Press {ctrl+p} to see all available actions and commands",
|
|
87
|
+
"The leader key is {ctrl+x}; combine with other keys for quick actions",
|
|
88
|
+
"Press {ctrl+x ?} to list the leader key actions",
|
|
89
|
+
"Press {ctrl+x b} in a session to show or hide the sidebar panel",
|
|
90
|
+
"Use {/new} or {ctrl+x n} to start a fresh conversation session",
|
|
91
|
+
"Use {/resume} or {ctrl+x l} to continue an earlier session",
|
|
92
|
+
"Use {/model} or {ctrl+l} to switch between available AI models",
|
|
93
|
+
"Use {/tree} to jump back to any earlier point in the session",
|
|
94
|
+
"Use {/fork} to branch a new session from an earlier message",
|
|
95
|
+
"Use {/compact} to summarize older history and free up context",
|
|
96
|
+
"Use {/export} to save the session as HTML or JSONL",
|
|
97
|
+
"Use {/copy} or {ctrl+x y} to copy the assistant's last message",
|
|
98
|
+
"Use {/name} to give this session a recognizable name",
|
|
99
|
+
"Press {ctrl+o} to collapse or expand tool output",
|
|
100
|
+
"Press {ctrl+t} to collapse or expand thinking blocks",
|
|
101
|
+
"Press {shift+enter} to add newlines in your prompt",
|
|
102
|
+
"Press {ctrl+g} to compose messages in your external editor",
|
|
103
|
+
"Press {escape} to stop the AI mid-response",
|
|
104
|
+
"Type {@} to search for a file and add it to your prompt",
|
|
105
|
+
"Start your prompt with {!} to run a shell command",
|
|
106
|
+
"Use {/hotkeys} to show all keyboard shortcuts",
|
|
107
|
+
"Use {/reload} to reload extensions, themes and keybindings",
|
|
108
|
+
];
|
|
109
|
+
/** Splits a tip into plain and highlighted parts. */
|
|
110
|
+
export function parseTip(tip) {
|
|
111
|
+
const parts = [];
|
|
112
|
+
const re = /\{([^}]*)\}/g;
|
|
113
|
+
let at = 0;
|
|
114
|
+
for (const m of tip.matchAll(re)) {
|
|
115
|
+
if (m.index > at)
|
|
116
|
+
parts.push({ text: tip.slice(at, m.index), highlight: false });
|
|
117
|
+
parts.push({ text: m[1], highlight: true });
|
|
118
|
+
at = m.index + m[0].length;
|
|
119
|
+
}
|
|
120
|
+
if (at < tip.length)
|
|
121
|
+
parts.push({ text: tip.slice(at), highlight: false });
|
|
122
|
+
return parts;
|
|
123
|
+
}
|
|
124
|
+
/** "● Tip …" (OpenCode: warning-coloured label, highlights in text, the rest muted), centred. */
|
|
125
|
+
export function renderTip(theme, width, index) {
|
|
126
|
+
const tip = TIPS[((index % TIPS.length) + TIPS.length) % TIPS.length];
|
|
127
|
+
const body = parseTip(tip)
|
|
128
|
+
.map((p) => theme.fg(p.highlight ? "text" : "muted", p.text))
|
|
129
|
+
.join("");
|
|
130
|
+
const label = theme.fg("warning", "● Tip ");
|
|
131
|
+
const max = Math.min(width, HOME_PROMPT_WIDTH);
|
|
132
|
+
const lines = wrapTextWithAnsi(body, Math.max(10, max - 6));
|
|
133
|
+
const out = lines.map((l, i) => `${i === 0 ? label : " "}${l}`);
|
|
134
|
+
// One left edge for every line, so wrapped lines stay aligned under the first.
|
|
135
|
+
const w = Math.max(...out.map((l) => visibleWidth(l)));
|
|
136
|
+
const pad = " ".repeat(Math.max(0, Math.floor((width - w) / 2)));
|
|
137
|
+
return out.map((l) => truncateToWidth(pad + l, width, ""));
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Bottom bar (OpenCode home/footer.tsx): 1 row of padding above and below, 2 columns at the sides;
|
|
141
|
+
* the folder and branch (with the branch icon) muted and the MCP count with a coloured dot on the left; on the right, the most
|
|
142
|
+
* recent other session in this directory and the leader key that resumes it. With `piVersion`, the
|
|
143
|
+
* brand with the pi and chassis versions comes first on the same line.
|
|
144
|
+
*/
|
|
145
|
+
export function renderHomeFooter(state, theme, width, piVersion) {
|
|
146
|
+
const cwd = homeRelative(state.ctx?.cwd ?? process.cwd());
|
|
147
|
+
const branch = state.git?.branch;
|
|
148
|
+
// Same branch icon as the sidebar and footer: the folder, then the icon and the branch name.
|
|
149
|
+
const left = [theme.fg("muted", cwd) + (branch ? ` ${leadIcon(branchGlyph(), (s) => theme.fg("accent", s))}${theme.fg("muted", branch)}${worktreeTag(theme, state.git)}` : "")];
|
|
150
|
+
const mcp = widgetsFor("sidebar").find((w) => w.id === "mcp");
|
|
151
|
+
if (mcp) {
|
|
152
|
+
try {
|
|
153
|
+
const view = mcp.render({ theme, ctx: state.ctx, width });
|
|
154
|
+
if (view?.text) {
|
|
155
|
+
const connected = view.text.split("/")[0] ?? view.text;
|
|
156
|
+
const dot = view.level === "error" ? "error" : view.level === "warn" ? "warning" : view.level === "ok" ? "success" : "muted";
|
|
157
|
+
left.push(`${theme.fg(dot, "●")} ${theme.fg("text", `${connected} MCP`)}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// widget error: leave it out
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// The brand with the pi and chassis versions leads the line.
|
|
165
|
+
if (piVersion)
|
|
166
|
+
left.unshift(brandLine(theme, piVersion, chassisVersion()));
|
|
167
|
+
const leftStr = left.join(" ");
|
|
168
|
+
// Right: the most recent other session here, with the leader key that resumes it.
|
|
169
|
+
const last = state.lastSession;
|
|
170
|
+
const inner = width - 4;
|
|
171
|
+
if (last) {
|
|
172
|
+
const tail = `${theme.fg("dim", fmtAgo(Date.now() - last.modified.getTime()))} ${theme.fg("text", "ctrl+x r")}`;
|
|
173
|
+
const mark = icon("resume");
|
|
174
|
+
const room = inner - visibleWidth(leftStr) - 4 - visibleWidth(tail) - 4; // gap, "⟲ ", quotes, spaces
|
|
175
|
+
if (room >= 8) {
|
|
176
|
+
// The label comes from a session file, so it is cleaned here whatever put it in the state.
|
|
177
|
+
const label = truncateToWidth(sanitize(last.label) || "untitled", Math.min(40, room), "…");
|
|
178
|
+
const right = `${leadIcon(mark, (s) => theme.fg("muted", s))}${theme.fg("muted", `"${label}"`)} ${tail}`;
|
|
179
|
+
const gap = inner - visibleWidth(leftStr) - visibleWidth(right);
|
|
180
|
+
return ["", ` ${leftStr}${" ".repeat(Math.max(2, gap))}${right}`, ""];
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return ["", ` ${truncateToWidth(leftStr, inner, "…")}`, ""];
|
|
184
|
+
}
|