u-foo 3.0.9 → 3.0.11
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 +25 -9
- package/README.zh-CN.md +22 -9
- package/dist/tui/darwin-arm64/ufoo-tui +0 -0
- package/dist/tui/darwin-x64/ufoo-tui +0 -0
- package/dist/tui/linux-arm64/ufoo-tui +0 -0
- package/dist/tui/linux-x64/ufoo-tui +0 -0
- package/package.json +12 -4
- package/scripts/pack-tui.js +112 -0
- package/scripts/postinstall.js +11 -0
- package/src/agents/activity/activityReconcile.js +106 -0
- package/src/agents/activity/activityStatePublisher.js +31 -2
- package/src/agents/activity/index.js +1 -0
- package/src/agents/launch/launcher.js +19 -0
- package/src/agents/launch/ptyRunner.js +20 -1
- package/src/app/chat/ChatController.js +433 -0
- package/src/app/chat/agentDirectory.js +63 -0
- package/src/app/chat/agentEnter.js +70 -0
- package/src/app/chat/agentIdentity.js +50 -0
- package/src/app/chat/bootstrap.js +66 -0
- package/src/app/chat/commandExecutor.js +108 -0
- package/src/app/chat/commands.js +38 -1
- package/src/app/chat/dashboardView.js +6 -2
- package/src/app/chat/historyStore.js +181 -0
- package/src/app/chat/index.js +14 -2
- package/src/app/chat/inputSubmitHandler.js +21 -7
- package/src/app/chat/ipcBuilders.js +52 -0
- package/src/app/chat/multiWindow/paneManager.js +10 -1
- package/src/app/chat/multiWindow/renderer.js +1 -1
- package/src/app/chat/multiWindow/vtFrame.js +93 -0
- package/src/app/chat/streamState.js +182 -0
- package/src/app/cli/features/doctor.js +22 -0
- package/src/code/UcodeController.js +156 -0
- package/src/code/context/planGraphService.js +4 -0
- package/src/code/repl.js +4 -3
- package/src/code/runtime/taskLoop.js +46 -50
- package/src/code/tui.js +13 -2
- package/src/code/ucodeSlashDispatch.js +241 -0
- package/src/coordination/bus/activate.js +3 -0
- package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
- package/src/runtime/contracts/uiProtocol.js +190 -0
- package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
- package/src/ui/dashboardBridge.js +81 -0
- package/src/ui/format/index.js +2 -2
- package/src/ui/index.js +8 -4
- package/src/ui/multiPaneBusMirror.js +137 -0
- package/src/ui/multiWindowHandoff.js +232 -0
- package/src/ui/ptyHandoff.js +23 -0
- package/src/ui/rustChatHost.js +1520 -0
- package/src/ui/rustMultiSession.js +497 -0
- package/src/ui/rustUcodeHost.js +999 -0
- package/src/ui/scrollbackReplay.js +82 -0
- package/src/ui/settingsBridge.js +49 -0
- package/src/ui/toolMergeBridge.js +66 -0
- package/src/ui/tuiLauncher.js +105 -0
- package/src/ui/ucodeStatusLine.js +74 -0
- package/src/ui/uiHostServer.js +339 -0
- package/src/ui/MIGRATION.md +0 -334
- package/src/ui/ink/ChatApp.js +0 -4152
- package/src/ui/ink/DashboardBar.js +0 -691
- package/src/ui/ink/InkDemo.js +0 -96
- package/src/ui/ink/MultilineInput.js +0 -662
- package/src/ui/ink/UcodeApp.js +0 -1675
- package/src/ui/ink/agentMirror.js +0 -730
- package/src/ui/ink/chatReducer.js +0 -473
- package/src/ui/runInk.js +0 -66
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Node host for ufoo-ui/1 handshake / event fanout.
|
|
5
|
+
*
|
|
6
|
+
* Security:
|
|
7
|
+
* - Unix socket chmod 0o600 after listen
|
|
8
|
+
* - Shared auth_token required in hello payload (timing-safe compare)
|
|
9
|
+
* - Commands rejected until the client authenticates
|
|
10
|
+
* - Soft frame-size cap to avoid unbounded buffering
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const net = require("net");
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const crypto = require("crypto");
|
|
16
|
+
const {
|
|
17
|
+
PROTOCOL,
|
|
18
|
+
createEnvelope,
|
|
19
|
+
encodeMessage,
|
|
20
|
+
decodeMessage,
|
|
21
|
+
createSeqCounter,
|
|
22
|
+
} = require("../runtime/contracts/uiProtocol");
|
|
23
|
+
|
|
24
|
+
function extractClientCapabilities(env) {
|
|
25
|
+
const payload = env && env.payload && typeof env.payload === "object" ? env.payload : {};
|
|
26
|
+
const raw = payload.capabilities;
|
|
27
|
+
if (!Array.isArray(raw)) return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const item of raw) {
|
|
30
|
+
if (typeof item === "string" && item) out.push(item);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const MAX_BUFFER_BYTES = 1024 * 1024;
|
|
36
|
+
const SOCKET_MODE = 0o600;
|
|
37
|
+
|
|
38
|
+
function createAuthToken() {
|
|
39
|
+
return crypto.randomBytes(32).toString("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tokensEqual(expected, actual) {
|
|
43
|
+
const a = Buffer.from(String(expected || ""), "utf8");
|
|
44
|
+
const b = Buffer.from(String(actual || ""), "utf8");
|
|
45
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
46
|
+
return crypto.timingSafeEqual(a, b);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createUiHostServer({
|
|
50
|
+
socketPath,
|
|
51
|
+
authToken = createAuthToken(),
|
|
52
|
+
packageVersion = require("../../package.json").version,
|
|
53
|
+
capabilities = ["chat"],
|
|
54
|
+
onCommand = null,
|
|
55
|
+
onClientReady = null,
|
|
56
|
+
maxBufferBytes = MAX_BUFFER_BYTES,
|
|
57
|
+
} = {}) {
|
|
58
|
+
if (!socketPath) throw new Error("socketPath required");
|
|
59
|
+
if (!authToken) throw new Error("authToken required");
|
|
60
|
+
try {
|
|
61
|
+
fs.unlinkSync(socketPath);
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const seq = createSeqCounter();
|
|
67
|
+
const clients = new Set();
|
|
68
|
+
// Latest authenticated client's advertised capabilities. Multi-window
|
|
69
|
+
// gating reads this to decide whether the Rust TUI can render frames.
|
|
70
|
+
let clientCapabilities = [];
|
|
71
|
+
// Lossy backpressure: when a client socket is saturated (write returns
|
|
72
|
+
// false), keep only the latest envelope per key until drain.
|
|
73
|
+
const lossyPendingByClient = new WeakMap();
|
|
74
|
+
|
|
75
|
+
function isLossyEnvelope(envelope) {
|
|
76
|
+
if (!envelope || envelope.kind !== "event") return false;
|
|
77
|
+
// createLossyEvent omits seq; ordered events always have a number seq.
|
|
78
|
+
return envelope.seq == null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function lossyKey(envelope) {
|
|
82
|
+
if (envelope && envelope.name === "multi.pane.frame") {
|
|
83
|
+
const id = envelope.payload && envelope.payload.agent_id;
|
|
84
|
+
return `frame:${id || ""}`;
|
|
85
|
+
}
|
|
86
|
+
return `event:${(envelope && envelope.name) || ""}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function flushLossyPending(client) {
|
|
90
|
+
const pending = lossyPendingByClient.get(client);
|
|
91
|
+
if (!pending || pending.size === 0) {
|
|
92
|
+
client.__ufooBackpressure = false;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const entries = [...pending.entries()];
|
|
96
|
+
pending.clear();
|
|
97
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
98
|
+
const [key, env] = entries[i];
|
|
99
|
+
try {
|
|
100
|
+
const ok = client.write(encodeMessage(env));
|
|
101
|
+
if (ok === false) {
|
|
102
|
+
pending.set(key, env);
|
|
103
|
+
for (let j = i + 1; j < entries.length; j += 1) {
|
|
104
|
+
pending.set(entries[j][0], entries[j][1]);
|
|
105
|
+
}
|
|
106
|
+
client.__ufooBackpressure = true;
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
clients.delete(client);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
client.__ufooBackpressure = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function ensureDrainHook(client) {
|
|
118
|
+
if (client.__ufooDrainHooked) return;
|
|
119
|
+
client.__ufooDrainHooked = true;
|
|
120
|
+
client.on("drain", () => {
|
|
121
|
+
client.__ufooBackpressure = false;
|
|
122
|
+
flushLossyPending(client);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function broadcast(envelope) {
|
|
127
|
+
const line = encodeMessage(envelope);
|
|
128
|
+
const lossy = isLossyEnvelope(envelope);
|
|
129
|
+
for (const client of clients) {
|
|
130
|
+
try {
|
|
131
|
+
if (lossy && client.__ufooBackpressure) {
|
|
132
|
+
let pending = lossyPendingByClient.get(client);
|
|
133
|
+
if (!pending) {
|
|
134
|
+
pending = new Map();
|
|
135
|
+
lossyPendingByClient.set(client, pending);
|
|
136
|
+
}
|
|
137
|
+
pending.set(lossyKey(envelope), envelope);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const ok = client.write(line);
|
|
141
|
+
if (ok === false && lossy) {
|
|
142
|
+
client.__ufooBackpressure = true;
|
|
143
|
+
ensureDrainHook(client);
|
|
144
|
+
let pending = lossyPendingByClient.get(client);
|
|
145
|
+
if (!pending) {
|
|
146
|
+
pending = new Map();
|
|
147
|
+
lossyPendingByClient.set(client, pending);
|
|
148
|
+
}
|
|
149
|
+
pending.set(lossyKey(envelope), envelope);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
clients.delete(client);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function send(client, envelope) {
|
|
158
|
+
try {
|
|
159
|
+
client.write(encodeMessage(envelope));
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore write failures on closing sockets
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function rejectAndClose(socket, message) {
|
|
166
|
+
send(socket, createEnvelope({
|
|
167
|
+
kind: "error",
|
|
168
|
+
name: "protocol",
|
|
169
|
+
seq: seq.next(),
|
|
170
|
+
payload: { ok: false, error: message },
|
|
171
|
+
}));
|
|
172
|
+
try {
|
|
173
|
+
socket.destroy();
|
|
174
|
+
} catch {
|
|
175
|
+
// ignore
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const server = net.createServer((socket) => {
|
|
180
|
+
clients.add(socket);
|
|
181
|
+
let authenticated = false;
|
|
182
|
+
let buffer = Buffer.alloc(0);
|
|
183
|
+
|
|
184
|
+
socket.on("data", (chunk) => {
|
|
185
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
186
|
+
if (buffer.length > maxBufferBytes) {
|
|
187
|
+
rejectAndClose(socket, "frame buffer exceeded");
|
|
188
|
+
clients.delete(socket);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let newline;
|
|
193
|
+
while ((newline = buffer.indexOf(0x0a)) >= 0) {
|
|
194
|
+
const lineBuf = buffer.subarray(0, newline);
|
|
195
|
+
buffer = buffer.subarray(newline + 1);
|
|
196
|
+
const line = lineBuf.toString("utf8");
|
|
197
|
+
if (!line.trim()) continue;
|
|
198
|
+
|
|
199
|
+
const decoded = decodeMessage(line);
|
|
200
|
+
if (!decoded.ok) {
|
|
201
|
+
send(socket, createEnvelope({
|
|
202
|
+
kind: "error",
|
|
203
|
+
name: "protocol",
|
|
204
|
+
seq: seq.next(),
|
|
205
|
+
payload: { ok: false, errors: decoded.errors },
|
|
206
|
+
}));
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const env = decoded.envelope;
|
|
210
|
+
|
|
211
|
+
if (env.kind === "hello") {
|
|
212
|
+
const token = env.payload && env.payload.auth_token;
|
|
213
|
+
if (!tokensEqual(authToken, token)) {
|
|
214
|
+
rejectAndClose(socket, "unauthorized");
|
|
215
|
+
clients.delete(socket);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
authenticated = true;
|
|
219
|
+
clientCapabilities = extractClientCapabilities(env);
|
|
220
|
+
send(socket, createEnvelope({
|
|
221
|
+
kind: "welcome",
|
|
222
|
+
seq: seq.next(),
|
|
223
|
+
payload: {
|
|
224
|
+
selected_protocol: PROTOCOL,
|
|
225
|
+
package_version: packageVersion,
|
|
226
|
+
capabilities,
|
|
227
|
+
},
|
|
228
|
+
}));
|
|
229
|
+
if (typeof onClientReady === "function") {
|
|
230
|
+
Promise.resolve(onClientReady(socket, env)).catch(() => {});
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (!authenticated) {
|
|
236
|
+
rejectAndClose(socket, "hello required");
|
|
237
|
+
clients.delete(socket);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (env.kind === "command" && typeof onCommand === "function") {
|
|
242
|
+
Promise.resolve(onCommand(env))
|
|
243
|
+
.then((result) => {
|
|
244
|
+
send(socket, createEnvelope({
|
|
245
|
+
kind: "result",
|
|
246
|
+
name: env.name,
|
|
247
|
+
requestId: env.request_id,
|
|
248
|
+
seq: seq.next(),
|
|
249
|
+
payload: result && typeof result === "object" ? result : { ok: true },
|
|
250
|
+
}));
|
|
251
|
+
})
|
|
252
|
+
.catch((err) => {
|
|
253
|
+
send(socket, createEnvelope({
|
|
254
|
+
kind: "error",
|
|
255
|
+
name: env.name,
|
|
256
|
+
requestId: env.request_id,
|
|
257
|
+
seq: seq.next(),
|
|
258
|
+
payload: { ok: false, error: err && err.message ? err.message : String(err) },
|
|
259
|
+
}));
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
socket.on("close", () => clients.delete(socket));
|
|
265
|
+
socket.on("error", () => clients.delete(socket));
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
PROTOCOL,
|
|
270
|
+
socketPath,
|
|
271
|
+
authToken,
|
|
272
|
+
listen() {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
server.once("error", reject);
|
|
275
|
+
server.listen(socketPath, () => {
|
|
276
|
+
try {
|
|
277
|
+
fs.chmodSync(socketPath, SOCKET_MODE);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
server.close();
|
|
280
|
+
reject(err);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
resolve(socketPath);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
close() {
|
|
288
|
+
return new Promise((resolve) => {
|
|
289
|
+
for (const client of clients) {
|
|
290
|
+
try {
|
|
291
|
+
client.destroy();
|
|
292
|
+
} catch {
|
|
293
|
+
// ignore
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
clients.clear();
|
|
297
|
+
server.close(() => {
|
|
298
|
+
try {
|
|
299
|
+
fs.unlinkSync(socketPath);
|
|
300
|
+
} catch {
|
|
301
|
+
// ignore
|
|
302
|
+
}
|
|
303
|
+
resolve();
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
},
|
|
307
|
+
broadcast,
|
|
308
|
+
nextSeq: () => seq.next(),
|
|
309
|
+
createEvent(name, payload, scope = null) {
|
|
310
|
+
return createEnvelope({
|
|
311
|
+
kind: "event",
|
|
312
|
+
name,
|
|
313
|
+
seq: seq.next(),
|
|
314
|
+
scope,
|
|
315
|
+
payload,
|
|
316
|
+
});
|
|
317
|
+
},
|
|
318
|
+
// Lossy events (e.g. multi.pane.frame) omit the ordered `seq` so they
|
|
319
|
+
// cannot trigger the seq-gap resync path when frames are coalesced.
|
|
320
|
+
createLossyEvent(name, payload, scope = null) {
|
|
321
|
+
return createEnvelope({
|
|
322
|
+
kind: "event",
|
|
323
|
+
name,
|
|
324
|
+
scope,
|
|
325
|
+
payload,
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
getClientCapabilities() {
|
|
329
|
+
return clientCapabilities.slice();
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
module.exports = {
|
|
335
|
+
createUiHostServer,
|
|
336
|
+
createAuthToken,
|
|
337
|
+
SOCKET_MODE,
|
|
338
|
+
MAX_BUFFER_BYTES,
|
|
339
|
+
};
|
package/src/ui/MIGRATION.md
DELETED
|
@@ -1,334 +0,0 @@
|
|
|
1
|
-
# Ink TUI Migration Plan
|
|
2
|
-
|
|
3
|
-
Status: Ink is the only TUI for chat and ucode. The legacy blessed renderer
|
|
4
|
-
has been removed after the parity close-out.
|
|
5
|
-
|
|
6
|
-
## Why
|
|
7
|
-
|
|
8
|
-
The removed legacy chat, ucode and internal-agent TUIs used blessed. Blessed
|
|
9
|
-
is an unmaintained imperative widget tree with no modern equivalent of
|
|
10
|
-
React's component model, and it was awkward to extend (manual layout math,
|
|
11
|
-
manual redraws, no useful test harness).
|
|
12
|
-
|
|
13
|
-
ink (the React-for-terminals library, what Claude Code, Codex CLI fronts
|
|
14
|
-
and the Gemini CLI all use) gives us declarative components, flexbox
|
|
15
|
-
layout, hooks, and proper isolation of pure logic from rendering.
|
|
16
|
-
|
|
17
|
-
## Approach
|
|
18
|
-
|
|
19
|
-
- Ink is the renderer for both chat and ucode.
|
|
20
|
-
- Pure helpers live in `src/ui/format/`, so behaviour parity is enforced by
|
|
21
|
-
test rather than copy/paste.
|
|
22
|
-
- Components live in `src/ui/ink/`, written in plain JS via
|
|
23
|
-
`React.createElement` (no JSX, no build step) so jest stays vanilla.
|
|
24
|
-
- ink is loaded through `src/ui/runInk.js`, a thin CJS→ESM bridge so the
|
|
25
|
-
rest of the codebase stays CommonJS.
|
|
26
|
-
|
|
27
|
-
## Progress
|
|
28
|
-
|
|
29
|
-
- **P0** ✅ ink + react deps, runtime bridge, `<InkDemo>` smoke harness,
|
|
30
|
-
pure helpers extracted to `src/ui/format/`.
|
|
31
|
-
- **P1** ✅ ucode TUI ported to ink.
|
|
32
|
-
- **P2** ✅ folded into P3.6 (internal agent view).
|
|
33
|
-
- **P3** ✅ chat TUI ported to ink. Daemon
|
|
34
|
-
connection, dashboard (5 views), tool-merge, status spinner, history,
|
|
35
|
-
agent selection, raw-PTY mirror and internal bus agent view are wired.
|
|
36
|
-
- **P4** ✅ parity close-out complete: full `commandExecutor` dispatch,
|
|
37
|
-
`daemonMessageRouter` callback coverage, persisted history, BUS streams,
|
|
38
|
-
transient agent state, loop summary, project rail switching, cron/settings
|
|
39
|
-
dashboard actions, completion popup, and default Ink entrypoints.
|
|
40
|
-
|
|
41
|
-
## P1 ucode TUI — what's wired
|
|
42
|
-
|
|
43
|
-
| Feature | Status |
|
|
44
|
-
|---|---|
|
|
45
|
-
| Banner + version + session id header | ✅ |
|
|
46
|
-
| Scrolling `<Static>` log (1000 line cap) | ✅ |
|
|
47
|
-
| Multiline input (cursor math, Ctrl+A/E/B/F/D/H/K/U/W, Meta+B/F/D, `\\\n` continuation, Alt+Enter newline, CJK wrap) | ✅ |
|
|
48
|
-
| Up/Down history walk + agent-selection mode | ✅ |
|
|
49
|
-
| Ctrl+C exit, Ctrl+O expand last tool group | ✅ |
|
|
50
|
-
| Tool merge/freeze/expand state machine | ✅ |
|
|
51
|
-
| Spinner + phase status line (request/thinking/text/tool labels) | ✅ |
|
|
52
|
-
| Esc abort with `AbortController` and "Cancelling..." status | ✅ |
|
|
53
|
-
| Agents footer with single-line truncation + `+N more` hint | ✅ |
|
|
54
|
-
| `runSingleCommand` empty/exit/probe/help/error/tool/nl/ubus/resume/nl_bg kinds | ✅ |
|
|
55
|
-
| Background tasks ("BG x/y/z" suffix) | ✅ |
|
|
56
|
-
| ubus / resume / nl_bg branches | ✅ |
|
|
57
|
-
| autoBus polling | ✅ |
|
|
58
|
-
|
|
59
|
-
## Real-TTY checklist
|
|
60
|
-
|
|
61
|
-
```sh
|
|
62
|
-
./bin/ucode.js
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
### Editor
|
|
66
|
-
|
|
67
|
-
- [ ] Type, see characters appear with the cursor on the next cell.
|
|
68
|
-
- [ ] Backspace deletes one cell back; cursor stays correct on CJK.
|
|
69
|
-
- [ ] Left/Right arrows move the cursor; resetting preferred col.
|
|
70
|
-
- [ ] Up/Down on a single line walks input history.
|
|
71
|
-
- [ ] Up/Down on a multiline value moves between visual rows.
|
|
72
|
-
- [ ] `\` followed by Enter inserts a newline; Enter alone submits.
|
|
73
|
-
- [ ] Alt+Enter inserts a newline.
|
|
74
|
-
- [ ] Ctrl+A / Ctrl+E jump to row start / end.
|
|
75
|
-
- [ ] Ctrl+W deletes the previous word (also Meta+Backspace).
|
|
76
|
-
- [ ] Long pasted text doesn't lock up the renderer.
|
|
77
|
-
- [ ] Resize the terminal — input frame and footer span the new width.
|
|
78
|
-
|
|
79
|
-
### Status line
|
|
80
|
-
|
|
81
|
-
- [ ] Shows `UCODE · Ready` while idle.
|
|
82
|
-
- [ ] Shows a spinning indicator + phase ("Waiting for model...",
|
|
83
|
-
"Thinking...", "Generating response...", "Calling X...") during
|
|
84
|
-
`runNaturalLanguageTask`.
|
|
85
|
-
- [ ] Appends `(<elapsed> s, esc cancel)` when a task is in flight.
|
|
86
|
-
- [ ] Esc on a running task flips to "Cancelling..." then back to Ready.
|
|
87
|
-
|
|
88
|
-
### Tool calls
|
|
89
|
-
|
|
90
|
-
- [ ] Single tool call renders one line (`· tool · detail`).
|
|
91
|
-
- [ ] Two+ consecutive tool calls collapse to one row + `(Ctrl+O expand)`.
|
|
92
|
-
- [ ] Ctrl+O expands the most recent group with `│`/`└` branch markers
|
|
93
|
-
and only fires once per group.
|
|
94
|
-
- [ ] When text arrives between tool calls, the previous group freezes
|
|
95
|
-
into the log and a fresh group starts on the next tool call.
|
|
96
|
-
|
|
97
|
-
### Agents footer
|
|
98
|
-
|
|
99
|
-
- [ ] Shows `Agents: none │ No target agents` when nothing's online.
|
|
100
|
-
- [ ] Shows `Agents: @x @y ... │ ↓ select target · ←/→ switch` otherwise.
|
|
101
|
-
- [ ] At narrow widths, the row stays single-line, drops trailing chips
|
|
102
|
-
and emits ` +N more`.
|
|
103
|
-
- [ ] Down enters selection mode (first chip inverse). Left/Right cycle.
|
|
104
|
-
Up exits. Prompt prefix changes to `›@<name> ` when locked.
|
|
105
|
-
|
|
106
|
-
### Smoke
|
|
107
|
-
|
|
108
|
-
- [ ] `npx jest --runTestsByPath test/unit/ui/UcodeApp.test.js test/unit/code/ucodeTui.test.js --runInBand` passes.
|
|
109
|
-
- [ ] `npx jest --silent` passes; every ink suite stays green.
|
|
110
|
-
|
|
111
|
-
## Decision log
|
|
112
|
-
|
|
113
|
-
- **Don't fork ink.** Claude-code-fixed inlines a customised ink under
|
|
114
|
-
`src/ink/`; we don't need React 19 / ConcurrentRoot / IDE bridging,
|
|
115
|
-
so depending on the public `ink@5` keeps maintenance cost low.
|
|
116
|
-
- **Don't add JSX.** `React.createElement` keeps jest CJS happy and
|
|
117
|
-
avoids a build step. We can revisit if any single component grows
|
|
118
|
-
past ~600 lines and readability suffers.
|
|
119
|
-
- **Don't enable `--experimental-vm-modules` for jest.** The risk of
|
|
120
|
-
surprise ESM behaviour across the existing test suite is too high.
|
|
121
|
-
Component logic is exercised by pure-function tests; real TTY render
|
|
122
|
-
coverage remains a manual smoke step.
|
|
123
|
-
- **Codex isn't a useful reference.** Its TUI is a Rust ratatui app
|
|
124
|
-
(`codex-rs/tui`), not React-based. The architectural principle worth
|
|
125
|
-
borrowing is its hard split between TUI and core protocol.
|
|
126
|
-
|
|
127
|
-
## P2 dropped, folded into P3
|
|
128
|
-
|
|
129
|
-
The internal-agent view in chat is not an independent program. It is now
|
|
130
|
-
an Ink chat sub-mode, so the earlier plan to mount a separate subtree was
|
|
131
|
-
folded into P3.6.
|
|
132
|
-
|
|
133
|
-
## P3 audit (chat TUI surface)
|
|
134
|
-
|
|
135
|
-
Source: the current Ink host in `src/ui/ink/ChatApp.js`, with
|
|
136
|
-
shared daemon and command helpers under `src/app/chat/`. Highlights:
|
|
137
|
-
|
|
138
|
-
### Lifecycle
|
|
139
|
-
- Public entrypoint `runChat(projectRoot, { globalMode })` from
|
|
140
|
-
`src/app/chat/index.js`; it delegates directly to the Ink ChatApp.
|
|
141
|
-
- Runners injected via closures: `daemonCoordinator.send`,
|
|
142
|
-
`executeCommand`, `inputSubmitHandler.handleSubmit`,
|
|
143
|
-
`daemonMessageRouter.handleMessage`.
|
|
144
|
-
|
|
145
|
-
### View state machine
|
|
146
|
-
- `dashboardView` ∈ `projects | agents | mode | provider | cron`
|
|
147
|
-
- `focusMode` ∈ `input | dashboard` — toggled by Tab and arrow keys
|
|
148
|
-
- `globalMode` (boolean) + `globalScope` ∈ `controller | project` —
|
|
149
|
-
`globalMode=true` enables a multi-project rail; Esc/Enter walk the
|
|
150
|
-
scope ladder.
|
|
151
|
-
- `enterDashboardMode()` / `exitDashboardMode()` are the two transition
|
|
152
|
-
points; `setGlobalScope()` runs an async, debounced project switch.
|
|
153
|
-
|
|
154
|
-
### Input
|
|
155
|
-
- Submit accepts `@mention`, `@target`, `/command`, plain text and
|
|
156
|
-
numeric disambiguation (when `pending.disambiguate` is set).
|
|
157
|
-
- Editor keys cover Ctrl+A/E/B/F/D/H/K/U/W, Meta+B/F/D, arrows
|
|
158
|
-
(cursor + history when empty), Esc (3-layer: clear @target →
|
|
159
|
-
exit project scope → cancel input), bracketed paste, Tab (toggle
|
|
160
|
-
dashboard), PgUp/PgDn (scroll log).
|
|
161
|
-
- Completion fires on `/` and `@` with sources from command registry,
|
|
162
|
-
group templates, solo profiles and agent mentions; Up arrow jumps to
|
|
163
|
-
the latest suggestion.
|
|
164
|
-
- History is per-project, persisted to `input-history.jsonl` with
|
|
165
|
-
draft restoration on project switch.
|
|
166
|
-
|
|
167
|
-
### Daemon stack
|
|
168
|
-
- `daemonTransport` owns the socket path and retry policy.
|
|
169
|
-
- `daemonConnection` owns the queue + lifecycle (`connect`, `send`,
|
|
170
|
-
`requestStatus`, `close`, `markExit`, `switchConnection`,
|
|
171
|
-
`getState`).
|
|
172
|
-
- `daemonCoordinator` orchestrates project switches with a serialised
|
|
173
|
-
Promise chain.
|
|
174
|
-
- `daemonMessageRouter.handleMessage(msg)` is a stateless dispatcher
|
|
175
|
-
that turns daemon responses into log appends, dashboard updates, PTY
|
|
176
|
-
writes and transient agent state changes.
|
|
177
|
-
- `daemonReconnect.restartDaemonFlow()` provides a per-project lock for
|
|
178
|
-
daemon restarts.
|
|
179
|
-
|
|
180
|
-
### Shared helpers
|
|
181
|
-
- `cronScheduler` — `/cron start|stop|list` + the cron dashboard view.
|
|
182
|
-
- `settingsController` — launch mode / agent provider,
|
|
183
|
-
with daemon restart on mode/provider change. `autoResume` stays config/command-driven.
|
|
184
|
-
- `transientAgentState` — TTL-bounded `working / waiting_input /
|
|
185
|
-
blocked` markers per agent.
|
|
186
|
-
- `projectCloseController` — `requestCloseProject(index)` runs daemon
|
|
187
|
-
stop + project switch.
|
|
188
|
-
- `agentDirectory` — agent label resolution + window clamping (pure).
|
|
189
|
-
- `internalAgentLogHistory` — bus log replay for internal agents.
|
|
190
|
-
|
|
191
|
-
### Internal-agent sub-view
|
|
192
|
-
- Ink owns the agent sub-view inside ChatApp.
|
|
193
|
-
- PTY mirror mode uses `agentSockets.connectOutput/Input` and
|
|
194
|
-
`requestSnapshot`.
|
|
195
|
-
- Embedded bus mode keeps its own input value, cursor, log and status.
|
|
196
|
-
- `agentSockets.createAgentSockets` owns the PTY/bus socket
|
|
197
|
-
lifecycle.
|
|
198
|
-
|
|
199
|
-
### Layout (Ink)
|
|
200
|
-
- `ChatApp` owns the chat surface as a React tree under
|
|
201
|
-
`src/ui/ink/`.
|
|
202
|
-
- Dashboard, log, status, completion, internal-agent panes and input are
|
|
203
|
-
rendered from React state with ink flex layout. No blessed widget geometry
|
|
204
|
-
or controller layer remains.
|
|
205
|
-
|
|
206
|
-
### Commands
|
|
207
|
-
`/bus`, `/ctx`, `/daemon`, `/doctor`, `/cron`, `/group`, `/init`,
|
|
208
|
-
`/open`, `/launch`, `/project`, `/role`, `/solo`, `/settings`,
|
|
209
|
-
`/help`, plus nested subcommands (`/bus activate|list|rename|send|status`,
|
|
210
|
-
`/cron start|list|stop`, etc.). `commandExecutor.executeCommand(text)`
|
|
211
|
-
is the single dispatch point. `parseCommand(text)`, `parseAtTarget(text)`
|
|
212
|
-
and `shouldEchoCommandInChat(text)` are pure.
|
|
213
|
-
|
|
214
|
-
### Cross-cutting
|
|
215
|
-
- `text.js`: `escapeBlessed`, `stripBlessedTags`, `stripAnsi`,
|
|
216
|
-
`truncateAnsi`, `decodeEscapedNewlines`. The blessed-tag helpers are now
|
|
217
|
-
compatibility shims for older daemon/router log strings; ink call sites
|
|
218
|
-
strip or normalize those tags before rendering. The ANSI helpers stay
|
|
219
|
-
relevant.
|
|
220
|
-
- `rawKeyMap.keyToRaw(ch, key)`: converts ink-style key events to
|
|
221
|
-
PTY bytes for the agent view. Stays as-is.
|
|
222
|
-
- `transport.js`: `startDaemon`, `stopDaemon`, `connectWithRetry`.
|
|
223
|
-
Framework-agnostic, no migration needed.
|
|
224
|
-
|
|
225
|
-
### Removal notes
|
|
226
|
-
1. **Entry**: `src/app/chat/index.js` delegates directly to `runChatInk()`.
|
|
227
|
-
2. **ucode**: `src/code/tui.js` is a compatibility export wrapper around
|
|
228
|
-
`src/ui/format/` and `runUcodeInkTui()`.
|
|
229
|
-
3. **Controllers**: the blessed widget controllers and their tests were
|
|
230
|
-
removed with the fallback path.
|
|
231
|
-
4. **Markup**: old brace-tag helpers remain only where shared daemon/chat
|
|
232
|
-
helpers still emit or sanitize legacy log markup.
|
|
233
|
-
|
|
234
|
-
### P3 phase plan
|
|
235
|
-
|
|
236
|
-
| Step | Goal | Status |
|
|
237
|
-
|---|---|---|
|
|
238
|
-
| P3.1 | This audit | ✅ |
|
|
239
|
-
| P3.2 | Ink-only `runChat()` entrypoint | ✅ |
|
|
240
|
-
| P3.3 | ChatApp shell (banner + log + input + status) | ✅ |
|
|
241
|
-
| P3.4 | Five dashboard views as React components | ✅ |
|
|
242
|
-
| P3.5 | Daemon connection + PROMPT/BUS_SEND wiring | ✅ |
|
|
243
|
-
| P3.6 | Raw-PTY internal agent view as a ChatApp mode | ✅ |
|
|
244
|
-
| P3.7 | Real-TTY checklist | ✅ |
|
|
245
|
-
|
|
246
|
-
## P3 chat TUI — what's wired
|
|
247
|
-
|
|
248
|
-
| Feature | Status |
|
|
249
|
-
|---|---|
|
|
250
|
-
| Banner header (project + global mode + scope) | ✅ |
|
|
251
|
-
| Scrolling `<Static>` log (1000 line cap) | ✅ |
|
|
252
|
-
| Multiline input (P1 MultilineInput component) | ✅ |
|
|
253
|
-
| 5 dashboard views (projects/agents/mode/provider/cron) | ✅ |
|
|
254
|
-
| Tab toggles input/dashboard focus | ✅ |
|
|
255
|
-
| Up/Down history walk + agent selection mode | ✅ |
|
|
256
|
-
| Left/Right cycle agents while selected | ✅ |
|
|
257
|
-
| Spinner + phase status line | ✅ |
|
|
258
|
-
| Tool-merge state machine + Ctrl+O expand | ✅ |
|
|
259
|
-
| Daemon connect / send / status poll | ✅ |
|
|
260
|
-
| `PROMPT` for free text, `BUS_SEND` for `@target` | ✅ |
|
|
261
|
-
| `BUS_SEND_OK` / `RESPONSE` / `ERROR` / `STATUS` / `BUS` envelopes | ✅ |
|
|
262
|
-
| Raw PTY agent mirror (Enter on selected agent, Esc to leave) | ✅ |
|
|
263
|
-
| `daemonMessageRouter` (markdown streams, transient state, bus subview) | ✅ |
|
|
264
|
-
| `commandExecutor` full slash-command dispatch (`/cron`, `/group`, `/role`, `/settings` …) | ✅ |
|
|
265
|
-
| Slash + `@` autocomplete | ✅ |
|
|
266
|
-
| Input history persisted file load/save | ✅ |
|
|
267
|
-
| Cron dashboard actions | ✅ |
|
|
268
|
-
| Settings dashboard actions (launch mode, provider) | ✅ |
|
|
269
|
-
|
|
270
|
-
## Real-TTY checklist for chat
|
|
271
|
-
|
|
272
|
-
```sh
|
|
273
|
-
./bin/ufoo.js chat # project mode
|
|
274
|
-
./bin/ufoo.js chat --global # global controller mode
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
### Layout
|
|
278
|
-
- [ ] Banner shows the active project + global/project tag.
|
|
279
|
-
- [ ] `Agents:` footer, status line above input, log fills the rest.
|
|
280
|
-
- [ ] Resize the terminal — input frame and footer stay single-line.
|
|
281
|
-
|
|
282
|
-
### Input + history
|
|
283
|
-
- [ ] Type, Enter sends a `PROMPT`. Backspace, arrows, Ctrl+A/E etc.
|
|
284
|
-
behave the same as the ucode editor.
|
|
285
|
-
- [ ] Up/Down on an empty draft walks the in-memory history.
|
|
286
|
-
- [ ] `\` + Enter inserts a newline.
|
|
287
|
-
- [ ] Esc clears any active agent selection.
|
|
288
|
-
|
|
289
|
-
### Daemon
|
|
290
|
-
- [ ] On launch the daemon spawns automatically (look for the socket
|
|
291
|
-
under `~/.ufoo` or your project's `.ufoo`).
|
|
292
|
-
- [ ] Send a free-text message — daemon answers, status flips to
|
|
293
|
-
`Working on task...` and back to Ready.
|
|
294
|
-
- [ ] Type `@<agent> hi` (or select with arrow keys) — message is
|
|
295
|
-
sent via `BUS_SEND`, ack arrives as `✓ Message delivered`.
|
|
296
|
-
|
|
297
|
-
### Agents footer
|
|
298
|
-
- [ ] Tab into the dashboard, ↓ enters agent selection (first item
|
|
299
|
-
inverse), ←/→ cycles, ↑ exits.
|
|
300
|
-
- [ ] Enter on a selected agent attaches to its PTY (cleared screen +
|
|
301
|
-
scroll region + bottom hint bar). Esc returns to chat without
|
|
302
|
-
losing the previous draft or log.
|
|
303
|
-
|
|
304
|
-
### Tool-merge
|
|
305
|
-
- [ ] Daemon-driven tool calls collapse and `(Ctrl+O expand)` works
|
|
306
|
-
the same as ucode.
|
|
307
|
-
|
|
308
|
-
### Smoke
|
|
309
|
-
- [ ] `npx jest --runTestsByPath test/unit/ui/ChatApp.test.js test/unit/ui/chatReducer.test.js --runInBand` passes.
|
|
310
|
-
- [ ] `npx jest --runTestsByPath test/unit/ui/UcodeApp.test.js test/unit/code/ucodeTui.test.js --runInBand` passes.
|
|
311
|
-
- [ ] `npx jest --silent` passes; every ink suite stays green.
|
|
312
|
-
|
|
313
|
-
## P4 close-out
|
|
314
|
-
|
|
315
|
-
- **STATUS handler fix** — chat now reads `msg.data.active` /
|
|
316
|
-
`msg.data.active_meta` / `msg.data.cron.tasks` so the agents and cron
|
|
317
|
-
counts in the footer actually update.
|
|
318
|
-
- **Slash command dispatch** — `createCommandExecutor` is wired in Ink
|
|
319
|
-
with daemon stop/start/restart, cron IPC, project switching and agent
|
|
320
|
-
activation callbacks.
|
|
321
|
-
- **Input history persistence** — `<projectRoot>/.ufoo/chat/input-history.jsonl`
|
|
322
|
-
is loaded on mount and appended on every submit.
|
|
323
|
-
- **Daemon message routing** — Ink routes daemon envelopes through
|
|
324
|
-
`daemonMessageRouter`, including BUS phase status, transient states,
|
|
325
|
-
pending delivery markers, streams, close/launch refreshes and loop
|
|
326
|
-
summary dashboard display.
|
|
327
|
-
- **Inline completion popup** — `/<prefix>` matches commands from
|
|
328
|
-
`COMMAND_REGISTRY`; `@<prefix>` matches the live agents list. Tab
|
|
329
|
-
accepts the top suggestion. Pure helper `buildCompletions` lives in
|
|
330
|
-
`src/ui/format` with full jest coverage.
|
|
331
|
-
- **Exit hygiene** — Ctrl+C now flushes `\x1b[2J\x1b[H` so the shell
|
|
332
|
-
prompt comes back to a clean screen instead of sitting under the
|
|
333
|
-
final ink frame; `runUcodeInkTui` returns `{ code: 0 }` so
|
|
334
|
-
`agent.js`'s `process.exit(res.code)` no longer crashes.
|