osdy-pi 0.1.2 → 0.1.4
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/README.md +5 -2
- package/extensions/osdy-pi/animation.ts +59 -0
- package/extensions/osdy-pi/border.ts +49 -0
- package/extensions/osdy-pi/constants.ts +75 -0
- package/extensions/osdy-pi/format.ts +42 -0
- package/extensions/osdy-pi/metrics.ts +210 -0
- package/extensions/osdy-pi/runtime-helpers.ts +101 -0
- package/extensions/osdy-pi/runtime.ts +147 -0
- package/extensions/osdy-pi/types.ts +50 -0
- package/extensions/osdy-pi/ui.ts +259 -0
- package/extensions/osdy-pi/utils.ts +54 -0
- package/extensions/osdy-pi/working-controller.ts +87 -0
- package/extensions/osdy-pi.ts +4 -650
- package/mapche1.png +0 -0
- package/package.json +45 -31
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { applyOsdyPi, disableOsdyPi, notifyStatus } from "./runtime-helpers.js";
|
|
6
|
+
import type { OsdyState, WorkingWidgetState } from "./types.js";
|
|
7
|
+
import {
|
|
8
|
+
createWorkingController,
|
|
9
|
+
type WorkingController,
|
|
10
|
+
} from "./working-controller.js";
|
|
11
|
+
|
|
12
|
+
function scheduleOsdyRefresh(
|
|
13
|
+
delayMs: number,
|
|
14
|
+
pi: ExtensionAPI,
|
|
15
|
+
ctx: ExtensionContext,
|
|
16
|
+
state: OsdyState,
|
|
17
|
+
workingState: WorkingWidgetState,
|
|
18
|
+
): void {
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
if (state.enabled) applyOsdyPi(pi, ctx, state, workingState);
|
|
21
|
+
}, delayMs);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function claimOsdyVisualLayer(
|
|
25
|
+
pi: ExtensionAPI,
|
|
26
|
+
ctx: ExtensionContext,
|
|
27
|
+
state: OsdyState,
|
|
28
|
+
workingState: WorkingWidgetState,
|
|
29
|
+
): void {
|
|
30
|
+
if (!state.enabled) return;
|
|
31
|
+
applyOsdyPi(pi, ctx, state, workingState);
|
|
32
|
+
for (const delayMs of [300, 1000]) {
|
|
33
|
+
scheduleOsdyRefresh(delayMs, pi, ctx, state, workingState);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadSessionMetadata(
|
|
38
|
+
pi: ExtensionAPI,
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
state: OsdyState,
|
|
41
|
+
): void {
|
|
42
|
+
void pi
|
|
43
|
+
.exec("git", ["branch", "--show-current"], { cwd: ctx.cwd })
|
|
44
|
+
.then((result) => {
|
|
45
|
+
state.gitLabel = result.stdout.trim() || "detached";
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
state.gitLabel = "no-git";
|
|
49
|
+
});
|
|
50
|
+
void pi
|
|
51
|
+
.exec("find", [ctx.cwd, "-name", "AGENTS.md", "-o", "-name", "AGENTS.MD"], {
|
|
52
|
+
cwd: ctx.cwd,
|
|
53
|
+
})
|
|
54
|
+
.then((result) => {
|
|
55
|
+
state.agentsLabel = String(
|
|
56
|
+
result.stdout.split("\n").filter(Boolean).length,
|
|
57
|
+
);
|
|
58
|
+
})
|
|
59
|
+
.catch(() => {
|
|
60
|
+
state.agentsLabel = "0";
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseCommandAction(args: string): string {
|
|
65
|
+
return args.trim().split(/\s+/, 1)[0] || "status";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function enableOsdyPi(
|
|
69
|
+
pi: ExtensionAPI,
|
|
70
|
+
ctx: ExtensionContext,
|
|
71
|
+
state: OsdyState,
|
|
72
|
+
workingState: WorkingWidgetState,
|
|
73
|
+
controller: WorkingController,
|
|
74
|
+
): void {
|
|
75
|
+
state.enabled = true;
|
|
76
|
+
applyOsdyPi(pi, ctx, state, workingState, true);
|
|
77
|
+
controller.refreshWorking();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function disableOsdyPiCommand(
|
|
81
|
+
ctx: ExtensionContext,
|
|
82
|
+
state: OsdyState,
|
|
83
|
+
controller: WorkingController,
|
|
84
|
+
): void {
|
|
85
|
+
state.enabled = false;
|
|
86
|
+
controller.stopWorking();
|
|
87
|
+
disableOsdyPi(ctx, state);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function registerCommand(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
state: OsdyState,
|
|
93
|
+
workingState: WorkingWidgetState,
|
|
94
|
+
controller: WorkingController,
|
|
95
|
+
): void {
|
|
96
|
+
pi.registerCommand("osdy-pi", {
|
|
97
|
+
description: "Manage the Osdy Pi experience: enable, disable, or status.",
|
|
98
|
+
getArgumentCompletions(prefix: string) {
|
|
99
|
+
return ["enable", "disable", "status"]
|
|
100
|
+
.filter((value) => value.startsWith(prefix.trim()))
|
|
101
|
+
.map((value) => ({ value, label: value }));
|
|
102
|
+
},
|
|
103
|
+
handler: (args, ctx) => {
|
|
104
|
+
const action = parseCommandAction(args);
|
|
105
|
+
if (action === "enable") {
|
|
106
|
+
enableOsdyPi(pi, ctx, state, workingState, controller);
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
if (action === "disable") {
|
|
110
|
+
disableOsdyPiCommand(ctx, state, controller);
|
|
111
|
+
return Promise.resolve();
|
|
112
|
+
}
|
|
113
|
+
if (action === "status") {
|
|
114
|
+
notifyStatus(ctx, state);
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
}
|
|
117
|
+
ctx.ui.notify("Usage: /osdy-pi enable | disable | status", "warning");
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function registerOsdyPi(pi: ExtensionAPI): void {
|
|
124
|
+
const state: OsdyState = { enabled: true, gitLabel: "-", agentsLabel: "-" };
|
|
125
|
+
const workingState: WorkingWidgetState = {
|
|
126
|
+
active: false,
|
|
127
|
+
label: "Working...",
|
|
128
|
+
frame: 0,
|
|
129
|
+
timer: undefined,
|
|
130
|
+
tui: undefined,
|
|
131
|
+
};
|
|
132
|
+
const controller = createWorkingController(state, workingState);
|
|
133
|
+
|
|
134
|
+
pi.on("agent_start", () => controller.onAgentStart());
|
|
135
|
+
pi.on("agent_end", () => controller.onAgentEnd());
|
|
136
|
+
pi.on("tool_execution_start", (event) =>
|
|
137
|
+
controller.onToolStart(event.toolName),
|
|
138
|
+
);
|
|
139
|
+
pi.on("tool_execution_end", () => controller.onToolEnd());
|
|
140
|
+
pi.on("session_shutdown", () => controller.onShutdown());
|
|
141
|
+
pi.on("session_start", (_event, ctx) => {
|
|
142
|
+
loadSessionMetadata(pi, ctx, state);
|
|
143
|
+
claimOsdyVisualLayer(pi, ctx, state, workingState);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
registerCommand(pi, state, workingState, controller);
|
|
147
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export type AnimationMode = "off" | "intro" | "continuous";
|
|
4
|
+
|
|
5
|
+
export type OsdyState = {
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
previousThemeName?: string;
|
|
8
|
+
gitLabel: string;
|
|
9
|
+
agentsLabel: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type WorkingWidgetState = {
|
|
13
|
+
active: boolean;
|
|
14
|
+
label: string;
|
|
15
|
+
frame: number;
|
|
16
|
+
timer: ReturnType<typeof setInterval> | undefined;
|
|
17
|
+
tui: TUI | undefined;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type AssistantUsage = {
|
|
21
|
+
input?: number;
|
|
22
|
+
output?: number;
|
|
23
|
+
cacheRead?: number;
|
|
24
|
+
cacheWrite?: number;
|
|
25
|
+
cost?: {
|
|
26
|
+
total?: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type AssistantSessionEntry = {
|
|
31
|
+
type: "message";
|
|
32
|
+
id: string;
|
|
33
|
+
parentId: string | null;
|
|
34
|
+
timestamp: string;
|
|
35
|
+
message?: {
|
|
36
|
+
role?: string;
|
|
37
|
+
usage?: AssistantUsage;
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type HeaderMetaRow = {
|
|
42
|
+
leftLabel: string;
|
|
43
|
+
leftValue: string;
|
|
44
|
+
rightLabel: string;
|
|
45
|
+
rightValue: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type SimpleTheme = {
|
|
49
|
+
fg(name: string, text: string): string;
|
|
50
|
+
};
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CustomEditor,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type ExtensionContext,
|
|
5
|
+
type KeybindingsManager,
|
|
6
|
+
type ReadonlyFooterDataProvider,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
type Component,
|
|
10
|
+
type EditorTheme,
|
|
11
|
+
type TUI,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
} from "@earendil-works/pi-tui";
|
|
15
|
+
import { animateAsciiLine, asciiAnimationMode } from "./animation.js";
|
|
16
|
+
import { fitBorder } from "./border.js";
|
|
17
|
+
import {
|
|
18
|
+
ANIMATION_INTERVAL_MS,
|
|
19
|
+
HEADER,
|
|
20
|
+
HEADER_FALLBACK,
|
|
21
|
+
HEADER_WIDTH,
|
|
22
|
+
INTRO_ANIMATION_FRAMES,
|
|
23
|
+
MASCOT,
|
|
24
|
+
MASCOT_GAP,
|
|
25
|
+
MASCOT_MIN_ROWS,
|
|
26
|
+
MASCOT_WIDTH,
|
|
27
|
+
WORKING_SPINNER_FRAMES,
|
|
28
|
+
} from "./constants.js";
|
|
29
|
+
import { formatPath } from "./format.js";
|
|
30
|
+
import {
|
|
31
|
+
modelLabel,
|
|
32
|
+
renderHeaderMetadata,
|
|
33
|
+
renderMetaRows,
|
|
34
|
+
usageLabel,
|
|
35
|
+
} from "./metrics.js";
|
|
36
|
+
import type { OsdyState, SimpleTheme, WorkingWidgetState } from "./types.js";
|
|
37
|
+
import {
|
|
38
|
+
composeSideBySide,
|
|
39
|
+
fitCenterVisible,
|
|
40
|
+
internalLineTarget,
|
|
41
|
+
sanitizeStatusText,
|
|
42
|
+
} from "./utils.js";
|
|
43
|
+
|
|
44
|
+
class OsdyFooter implements Component {
|
|
45
|
+
private readonly ctx: ExtensionContext;
|
|
46
|
+
private readonly footerData: ReadonlyFooterDataProvider;
|
|
47
|
+
private readonly theme: SimpleTheme;
|
|
48
|
+
|
|
49
|
+
constructor(
|
|
50
|
+
ctx: ExtensionContext,
|
|
51
|
+
footerData: ReadonlyFooterDataProvider,
|
|
52
|
+
theme: SimpleTheme,
|
|
53
|
+
) {
|
|
54
|
+
this.ctx = ctx;
|
|
55
|
+
this.footerData = footerData;
|
|
56
|
+
this.theme = theme;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
render(width: number): string[] {
|
|
60
|
+
let location = formatPath(this.ctx.cwd);
|
|
61
|
+
const branch = this.footerData.getGitBranch?.();
|
|
62
|
+
if (branch) location = `${location} (${branch})`;
|
|
63
|
+
const locationLine = truncateToWidth(
|
|
64
|
+
this.theme.fg("dim", location),
|
|
65
|
+
width,
|
|
66
|
+
this.theme.fg("dim", "..."),
|
|
67
|
+
);
|
|
68
|
+
const statuses = this.footerData.getExtensionStatuses?.() ?? new Map();
|
|
69
|
+
const statusEntries = Array.from(statuses.entries());
|
|
70
|
+
const statusLine = statusEntries
|
|
71
|
+
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey))
|
|
72
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
73
|
+
.join(" ");
|
|
74
|
+
return statusLine
|
|
75
|
+
? [
|
|
76
|
+
locationLine,
|
|
77
|
+
truncateToWidth(statusLine, width, this.theme.fg("dim", "...")),
|
|
78
|
+
]
|
|
79
|
+
: [locationLine];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
invalidate(): void {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class OsdyWorkingWidget implements Component {
|
|
86
|
+
private readonly state: WorkingWidgetState;
|
|
87
|
+
private readonly theme: SimpleTheme;
|
|
88
|
+
|
|
89
|
+
constructor(state: WorkingWidgetState, theme: SimpleTheme) {
|
|
90
|
+
this.state = state;
|
|
91
|
+
this.theme = theme;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render(width: number): string[] {
|
|
95
|
+
if (!this.state.active) return [];
|
|
96
|
+
const frame =
|
|
97
|
+
WORKING_SPINNER_FRAMES[
|
|
98
|
+
this.state.frame % WORKING_SPINNER_FRAMES.length
|
|
99
|
+
] ??
|
|
100
|
+
WORKING_SPINNER_FRAMES[0] ??
|
|
101
|
+
"⠋";
|
|
102
|
+
const line = `${this.theme.fg("accent", frame)} ${this.theme.fg("muted", this.state.label)}`;
|
|
103
|
+
return [fitCenterVisible(line, width)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
invalidate(): void {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function createHeaderComponent(
|
|
110
|
+
pi: ExtensionAPI,
|
|
111
|
+
ctx: ExtensionContext,
|
|
112
|
+
state: OsdyState,
|
|
113
|
+
) {
|
|
114
|
+
return (_tui: TUI, theme: SimpleTheme) => {
|
|
115
|
+
let frame = 0;
|
|
116
|
+
let animationComplete = false;
|
|
117
|
+
const animationMode = asciiAnimationMode();
|
|
118
|
+
const animateAscii = animationMode !== "off";
|
|
119
|
+
const timer = animateAscii
|
|
120
|
+
? setInterval(() => {
|
|
121
|
+
frame += 1;
|
|
122
|
+
if (animationMode === "intro" && frame >= INTRO_ANIMATION_FRAMES) {
|
|
123
|
+
animationComplete = true;
|
|
124
|
+
clearInterval(timer);
|
|
125
|
+
}
|
|
126
|
+
_tui.requestRender();
|
|
127
|
+
}, ANIMATION_INTERVAL_MS)
|
|
128
|
+
: undefined;
|
|
129
|
+
return {
|
|
130
|
+
render(width: number): string[] {
|
|
131
|
+
const animationStyle =
|
|
132
|
+
animateAscii && !animationComplete ? "animated" : "static";
|
|
133
|
+
const showMascot =
|
|
134
|
+
_tui.terminal.rows >= MASCOT_MIN_ROWS &&
|
|
135
|
+
width >= MASCOT_WIDTH + MASCOT_GAP + HEADER_WIDTH;
|
|
136
|
+
const useFullHeader = width >= HEADER_WIDTH;
|
|
137
|
+
const headerLines = useFullHeader ? [...HEADER] : [...HEADER_FALLBACK];
|
|
138
|
+
const metadataRows = renderHeaderMetadata(pi, ctx, state, width);
|
|
139
|
+
const logoLines = headerLines.map((line, index) => {
|
|
140
|
+
const baseColor = useFullHeader && index >= 6 ? "mdLink" : "accent";
|
|
141
|
+
return animateAsciiLine(
|
|
142
|
+
line,
|
|
143
|
+
index,
|
|
144
|
+
frame,
|
|
145
|
+
theme,
|
|
146
|
+
baseColor,
|
|
147
|
+
"mdHeading",
|
|
148
|
+
"mdLink",
|
|
149
|
+
animationStyle,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
const mascotLines = MASCOT.map((line, index) =>
|
|
153
|
+
animateAsciiLine(
|
|
154
|
+
line,
|
|
155
|
+
index,
|
|
156
|
+
frame,
|
|
157
|
+
theme,
|
|
158
|
+
"muted",
|
|
159
|
+
"mdCode",
|
|
160
|
+
"muted",
|
|
161
|
+
animationStyle,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
const headerBlock = showMascot
|
|
165
|
+
? composeSideBySide(mascotLines, logoLines, width)
|
|
166
|
+
: logoLines.map((line) => fitCenterVisible(line, width));
|
|
167
|
+
return [
|
|
168
|
+
"",
|
|
169
|
+
...headerBlock,
|
|
170
|
+
"",
|
|
171
|
+
...renderMetaRows(metadataRows, width, theme),
|
|
172
|
+
"",
|
|
173
|
+
];
|
|
174
|
+
},
|
|
175
|
+
invalidate() {},
|
|
176
|
+
dispose() {
|
|
177
|
+
if (timer) clearInterval(timer);
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function createFooterComponent(
|
|
184
|
+
ctx: ExtensionContext,
|
|
185
|
+
footerData: ReadonlyFooterDataProvider,
|
|
186
|
+
theme: SimpleTheme,
|
|
187
|
+
): Component {
|
|
188
|
+
return new OsdyFooter(ctx, footerData, theme);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createWorkingWidgetFactory(workingState: WorkingWidgetState) {
|
|
192
|
+
return (tui: TUI, theme: SimpleTheme): Component => {
|
|
193
|
+
workingState.tui = tui;
|
|
194
|
+
return new OsdyWorkingWidget(workingState, theme);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function createEditorComponent(
|
|
199
|
+
pi: ExtensionAPI,
|
|
200
|
+
ctx: ExtensionContext,
|
|
201
|
+
): (
|
|
202
|
+
tui: TUI,
|
|
203
|
+
theme: EditorTheme,
|
|
204
|
+
keybindings: KeybindingsManager,
|
|
205
|
+
) => CustomEditor {
|
|
206
|
+
class OsdyEditor extends CustomEditor {
|
|
207
|
+
private readonly tuiRef: TUI;
|
|
208
|
+
|
|
209
|
+
constructor(
|
|
210
|
+
tuiRef: TUI,
|
|
211
|
+
theme: EditorTheme,
|
|
212
|
+
keybindings: KeybindingsManager,
|
|
213
|
+
) {
|
|
214
|
+
super(tuiRef, theme, keybindings, { paddingX: 1 });
|
|
215
|
+
this.tuiRef = tuiRef;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
override render(width: number): string[] {
|
|
219
|
+
if (width < 4 || this.isShowingAutocomplete()) return super.render(width);
|
|
220
|
+
const editorWidth = width;
|
|
221
|
+
const innerWidth = Math.max(1, editorWidth - 2);
|
|
222
|
+
const lines = super.render(innerWidth);
|
|
223
|
+
if (lines.length < 2) return lines;
|
|
224
|
+
let bottomIndex = Math.max(1, lines.length - 1);
|
|
225
|
+
const target = internalLineTarget(this.tuiRef);
|
|
226
|
+
const internalLines = bottomIndex - 1;
|
|
227
|
+
if (internalLines < target) {
|
|
228
|
+
const blank = " ".repeat(innerWidth);
|
|
229
|
+
const added = Array.from(
|
|
230
|
+
{ length: target - internalLines },
|
|
231
|
+
() => blank,
|
|
232
|
+
);
|
|
233
|
+
lines.splice(bottomIndex, 0, ...added);
|
|
234
|
+
bottomIndex += added.length;
|
|
235
|
+
}
|
|
236
|
+
const borderColor = (text: string) => this.borderColor(text);
|
|
237
|
+
const side = borderColor("│");
|
|
238
|
+
for (let index = 1; index < bottomIndex; index += 1) {
|
|
239
|
+
const content = truncateToWidth(lines[index] ?? "", innerWidth, "");
|
|
240
|
+
const padding = " ".repeat(
|
|
241
|
+
Math.max(0, innerWidth - visibleWidth(content)),
|
|
242
|
+
);
|
|
243
|
+
lines[index] = `${side}${content}${padding}${side}`;
|
|
244
|
+
}
|
|
245
|
+
const topLeft = ctx.ui.theme.fg("mdLink", " Osdy-Pi ");
|
|
246
|
+
const topRight = ctx.ui.theme.fg(
|
|
247
|
+
"muted",
|
|
248
|
+
` ${modelLabel(ctx)} · think ${pi.getThinkingLevel()} `,
|
|
249
|
+
);
|
|
250
|
+
const bottomLeft = ctx.ui.theme.fg("muted", usageLabel(ctx));
|
|
251
|
+
lines[0] = `${borderColor("╭")}${fitBorder(topLeft, topRight, editorWidth - 2, borderColor)}${borderColor("╮")}`;
|
|
252
|
+
lines[bottomIndex] =
|
|
253
|
+
`${borderColor("╰")}${fitBorder(bottomLeft, "", editorWidth - 2, borderColor)}${borderColor("╯")}`;
|
|
254
|
+
return lines;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (tui, theme, keybindings) => new OsdyEditor(tui, theme, keybindings);
|
|
259
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { HEADER_WIDTH, MASCOT_GAP, MASCOT_WIDTH } from "./constants.js";
|
|
4
|
+
|
|
5
|
+
export function sanitizeStatusText(text: string): string {
|
|
6
|
+
return text
|
|
7
|
+
.replace(/[\r\n\t]/g, " ")
|
|
8
|
+
.replace(/ +/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function positiveModulo(value: number, divisor: number): number {
|
|
13
|
+
return ((value % divisor) + divisor) % divisor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function centerVisible(line: string, width: number): string {
|
|
17
|
+
const leftPad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
|
|
18
|
+
return `${" ".repeat(leftPad)}${line}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fitCenterVisible(line: string, width: number): string {
|
|
22
|
+
return centerVisible(truncateToWidth(line, Math.max(1, width)), width);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function padVisibleRight(line: string, width: number): string {
|
|
26
|
+
return `${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getCombinedHeaderWidth(): number {
|
|
30
|
+
return MASCOT_WIDTH + MASCOT_GAP + HEADER_WIDTH;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function composeSideBySide(
|
|
34
|
+
leftLines: string[],
|
|
35
|
+
rightLines: string[],
|
|
36
|
+
width: number,
|
|
37
|
+
): string[] {
|
|
38
|
+
const combinedWidth = getCombinedHeaderWidth();
|
|
39
|
+
if (width < combinedWidth) return rightLines;
|
|
40
|
+
const leftPad = " ".repeat(
|
|
41
|
+
Math.max(0, Math.floor((width - combinedWidth) / 2)),
|
|
42
|
+
);
|
|
43
|
+
const rows = Math.max(leftLines.length, rightLines.length);
|
|
44
|
+
const topOffset = Math.max(0, Math.floor((rows - rightLines.length) / 2));
|
|
45
|
+
return Array.from({ length: rows }, (_, index) => {
|
|
46
|
+
const left = padVisibleRight(leftLines[index] ?? "", MASCOT_WIDTH);
|
|
47
|
+
const right = rightLines[index - topOffset] ?? "";
|
|
48
|
+
return `${leftPad}${left}${" ".repeat(MASCOT_GAP)}${right}`;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function internalLineTarget(tui: TUI): number {
|
|
53
|
+
return tui.terminal.rows < 18 ? 3 : 4;
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { OsdyState, WorkingWidgetState } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export type WorkingController = {
|
|
4
|
+
onAgentStart(): void;
|
|
5
|
+
onAgentEnd(): void;
|
|
6
|
+
onToolStart(toolName: string): void;
|
|
7
|
+
onToolEnd(): void;
|
|
8
|
+
onShutdown(): void;
|
|
9
|
+
refreshWorking(): void;
|
|
10
|
+
stopWorking(): void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createWorkingController(
|
|
14
|
+
state: OsdyState,
|
|
15
|
+
workingState: WorkingWidgetState,
|
|
16
|
+
): WorkingController {
|
|
17
|
+
let activeAgent = false;
|
|
18
|
+
let activeToolCount = 0;
|
|
19
|
+
|
|
20
|
+
const requestWorkingRender = () => workingState.tui?.requestRender();
|
|
21
|
+
const clearWorkingTimer = () => {
|
|
22
|
+
if (!workingState.timer) return;
|
|
23
|
+
clearInterval(workingState.timer);
|
|
24
|
+
workingState.timer = undefined;
|
|
25
|
+
};
|
|
26
|
+
const ensureWorkingTimer = () => {
|
|
27
|
+
if (workingState.timer) return;
|
|
28
|
+
workingState.timer = setInterval(() => {
|
|
29
|
+
workingState.frame += 1;
|
|
30
|
+
requestWorkingRender();
|
|
31
|
+
}, 80);
|
|
32
|
+
};
|
|
33
|
+
const setWorkingState = (active: boolean, label: string) => {
|
|
34
|
+
workingState.active = active;
|
|
35
|
+
workingState.label = label;
|
|
36
|
+
};
|
|
37
|
+
const startWorking = (label: string) => {
|
|
38
|
+
if (!state.enabled) return;
|
|
39
|
+
setWorkingState(true, label);
|
|
40
|
+
ensureWorkingTimer();
|
|
41
|
+
requestWorkingRender();
|
|
42
|
+
};
|
|
43
|
+
const stopWorking = () => {
|
|
44
|
+
setWorkingState(false, workingState.label);
|
|
45
|
+
clearWorkingTimer();
|
|
46
|
+
requestWorkingRender();
|
|
47
|
+
};
|
|
48
|
+
const getIdleLabel = () =>
|
|
49
|
+
activeToolCount > 0 ? "Running tool..." : "Working...";
|
|
50
|
+
const refreshWorking = () => {
|
|
51
|
+
if (!state.enabled || (!activeAgent && activeToolCount === 0)) {
|
|
52
|
+
stopWorking();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
startWorking(getIdleLabel());
|
|
56
|
+
};
|
|
57
|
+
const resetActivity = () => {
|
|
58
|
+
activeAgent = false;
|
|
59
|
+
activeToolCount = 0;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
onAgentStart(): void {
|
|
64
|
+
activeAgent = true;
|
|
65
|
+
refreshWorking();
|
|
66
|
+
},
|
|
67
|
+
onAgentEnd(): void {
|
|
68
|
+
resetActivity();
|
|
69
|
+
refreshWorking();
|
|
70
|
+
},
|
|
71
|
+
onToolStart(toolName: string): void {
|
|
72
|
+
activeToolCount += 1;
|
|
73
|
+
startWorking(`Running ${toolName}...`);
|
|
74
|
+
},
|
|
75
|
+
onToolEnd(): void {
|
|
76
|
+
activeToolCount = Math.max(0, activeToolCount - 1);
|
|
77
|
+
refreshWorking();
|
|
78
|
+
},
|
|
79
|
+
onShutdown(): void {
|
|
80
|
+
resetActivity();
|
|
81
|
+
stopWorking();
|
|
82
|
+
workingState.tui = undefined;
|
|
83
|
+
},
|
|
84
|
+
refreshWorking,
|
|
85
|
+
stopWorking,
|
|
86
|
+
};
|
|
87
|
+
}
|