document360-writer 0.4.0 → 0.4.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.
Files changed (87) hide show
  1. package/LICENSE +10 -16
  2. package/README.md +1 -1
  3. package/dist/cli.js +63 -123
  4. package/dist/commands/doctor.d.ts +13 -0
  5. package/dist/commands/index.d.ts +5 -1
  6. package/dist/commands/init.d.ts +7 -0
  7. package/dist/commands/login.d.ts +4 -0
  8. package/dist/commands/model.d.ts +16 -0
  9. package/dist/commands/profile.d.ts +3 -0
  10. package/dist/commands/publish.d.ts +12 -1
  11. package/dist/commands/scope.d.ts +9 -0
  12. package/dist/commands/sync.d.ts +8 -0
  13. package/dist/d360/authCli.d.ts +3 -0
  14. package/dist/lib/colors.d.ts +2 -0
  15. package/dist/lib/commandSuggest.d.ts +7 -0
  16. package/dist/lib/diffRender.d.ts +8 -0
  17. package/dist/lib/hyperlink.d.ts +5 -0
  18. package/dist/lib/inputLayout.d.ts +19 -0
  19. package/dist/lib/mascot.d.ts +5 -0
  20. package/dist/lib/modelChoices.d.ts +14 -0
  21. package/dist/lib/paste.d.ts +19 -0
  22. package/dist/lib/syncRender.d.ts +6 -0
  23. package/dist/lib/toolFormat.d.ts +6 -2
  24. package/dist/repl.d.ts +2 -0
  25. package/dist/tui/App.d.ts +1 -1
  26. package/dist/tui/catalog.d.ts +4 -0
  27. package/dist/tui/itemRender.d.ts +14 -0
  28. package/package.json +5 -4
  29. package/dist/cli.js.map +0 -1
  30. package/dist/commands/allowProd.js +0 -18
  31. package/dist/commands/allowProd.js.map +0 -1
  32. package/dist/commands/audit.js +0 -12
  33. package/dist/commands/audit.js.map +0 -1
  34. package/dist/commands/clear.js +0 -14
  35. package/dist/commands/clear.js.map +0 -1
  36. package/dist/commands/exit.js +0 -4
  37. package/dist/commands/exit.js.map +0 -1
  38. package/dist/commands/help.js +0 -30
  39. package/dist/commands/help.js.map +0 -1
  40. package/dist/commands/index.js +0 -44
  41. package/dist/commands/index.js.map +0 -1
  42. package/dist/commands/init.js +0 -83
  43. package/dist/commands/init.js.map +0 -1
  44. package/dist/commands/logsCli.js +0 -31
  45. package/dist/commands/logsCli.js.map +0 -1
  46. package/dist/commands/mcp.js +0 -103
  47. package/dist/commands/mcp.js.map +0 -1
  48. package/dist/commands/profile.js +0 -16
  49. package/dist/commands/profile.js.map +0 -1
  50. package/dist/commands/publish.js +0 -19
  51. package/dist/commands/publish.js.map +0 -1
  52. package/dist/commands/rename.js +0 -22
  53. package/dist/commands/rename.js.map +0 -1
  54. package/dist/commands/resume.js +0 -58
  55. package/dist/commands/resume.js.map +0 -1
  56. package/dist/commands/screenshot.js +0 -20
  57. package/dist/commands/screenshot.js.map +0 -1
  58. package/dist/commands/workspace.js +0 -8
  59. package/dist/commands/workspace.js.map +0 -1
  60. package/dist/d360/authCli.js +0 -106
  61. package/dist/d360/authCli.js.map +0 -1
  62. package/dist/d360/portalLink.js +0 -43
  63. package/dist/d360/portalLink.js.map +0 -1
  64. package/dist/d360/profileCli.js +0 -54
  65. package/dist/d360/profileCli.js.map +0 -1
  66. package/dist/d360/workspaceCli.js +0 -72
  67. package/dist/d360/workspaceCli.js.map +0 -1
  68. package/dist/lib/colors.js +0 -10
  69. package/dist/lib/colors.js.map +0 -1
  70. package/dist/lib/mdRender.js +0 -172
  71. package/dist/lib/mdRender.js.map +0 -1
  72. package/dist/lib/streaming.js +0 -51
  73. package/dist/lib/streaming.js.map +0 -1
  74. package/dist/lib/toolFormat.js +0 -115
  75. package/dist/lib/toolFormat.js.map +0 -1
  76. package/dist/oneShot.js +0 -96
  77. package/dist/oneShot.js.map +0 -1
  78. package/dist/repl.js +0 -287
  79. package/dist/repl.js.map +0 -1
  80. package/dist/tui/App.js +0 -607
  81. package/dist/tui/App.js.map +0 -1
  82. package/dist/tui/catalog.js +0 -22
  83. package/dist/tui/catalog.js.map +0 -1
  84. package/dist/tui/index.js +0 -24
  85. package/dist/tui/index.js.map +0 -1
  86. package/dist/tui/itemRender.js +0 -72
  87. package/dist/tui/itemRender.js.map +0 -1
package/dist/tui/App.js DELETED
@@ -1,607 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
4
- import { existsSync, readFileSync } from 'node:fs';
5
- import { basename, isAbsolute, join } from 'node:path';
6
- import { createSession, resolveActiveProfile, resolveProjectId, getArticle, decodeJwtClaims, isExpired, loadTokens, setTitle, slugify, touchSession, upsertSession, generateTitle, findByName, listSessions, renameSession, suggestNextAction, readProjectConfig, writeProjectConfig, } from 'document360-engine';
7
- import { publishCommand } from '../commands/publish.js';
8
- import { auditCommand } from '../commands/audit.js';
9
- import { screenshotCommand } from '../commands/screenshot.js';
10
- import { COMMANDS, filterCommands } from './catalog.js';
11
- import { renderItem, renderTranscript } from './itemRender.js';
12
- import { formatToolResult, formatToolUse } from '../lib/toolFormat.js';
13
- import { liveTail, streamCut } from '../lib/streaming.js';
14
- import { articleEditorUrl, extractArticleId, extractPublicUrl } from '../d360/portalLink.js';
15
- import { fetchWorkspaces, matchWorkspace, setWorkspace } from '../d360/workspaceCli.js';
16
- function computeBanner(cwd, version, auth, profileName) {
17
- const claude = auth.kind === 'api' ? 'API key' : auth.kind === 'subscription' ? 'subscription' : 'not configured';
18
- const info = { version, claude, who: null, profile: '—', apiUrl: '—', project: '—', cwd, prod: false, loggedOut: true };
19
- try {
20
- const active = resolveActiveProfile(cwd, profileName);
21
- info.profile = active.name;
22
- info.apiUrl = active.connection.apiUrl;
23
- info.prod = active.production;
24
- info.project = active.project.projectId ?? '(chosen at login)';
25
- const tokens = loadTokens(active.name);
26
- if (tokens && !(isExpired(tokens) && !tokens.refreshToken)) {
27
- const claims = { ...(decodeJwtClaims(tokens.idToken) ?? {}), ...(decodeJwtClaims(tokens.accessToken) ?? {}) };
28
- info.who = (claims['email'] ?? claims['preferred_username'] ?? 'signed in');
29
- info.loggedOut = false;
30
- }
31
- }
32
- catch {
33
- /* no/invalid profile config */
34
- }
35
- return info;
36
- }
37
- function statusLine(cwd, profileName) {
38
- try {
39
- const active = resolveActiveProfile(cwd, profileName);
40
- const tokens = loadTokens(active.name);
41
- if (!tokens)
42
- return { text: `profile "${active.name}" — not logged in (d360-writer login)`, prod: active.production };
43
- const claims = { ...(decodeJwtClaims(tokens.idToken) ?? {}), ...(decodeJwtClaims(tokens.accessToken) ?? {}) };
44
- const who = (claims['email'] ?? claims['preferred_username'] ?? 'signed in');
45
- if (isExpired(tokens) && !tokens.refreshToken)
46
- return { text: `profile "${active.name}" — session expired`, prod: active.production };
47
- return { text: `${who} · profile "${active.name}"`, prod: active.production };
48
- }
49
- catch (e) {
50
- return { text: e.message.split('.')[0], prod: false };
51
- }
52
- }
53
- // Doc-themed progress verbs (cycled while the agent works), à la Claude Code.
54
- const GERUNDS = [
55
- 'Drafting', 'Composing', 'Outlining', 'Researching', 'Documenting', 'Structuring',
56
- 'Polishing', 'Synthesizing', 'Curating', 'Distilling', 'Weaving', 'Wrangling', 'Pondering',
57
- ];
58
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
59
- const PLACEHOLDER = 'Ask me to write or update an article…';
60
- /**
61
- * Claude Code-style blinking block cursor: inverse-video over `ch` (the first
62
- * placeholder character when the input is empty, a space at the end while typing).
63
- */
64
- function Cursor({ ch, dim }) {
65
- const [on, setOn] = useState(true);
66
- useEffect(() => {
67
- const id = setInterval(() => setOn(o => !o), 530);
68
- return () => clearInterval(id);
69
- }, []);
70
- return (_jsx(Text, { inverse: on, color: dim && !on ? 'gray' : undefined, children: ch }));
71
- }
72
- /** D360 write tools whose results deserve a portal/live link. (unpublish excluded.) */
73
- const ARTICLE_WRITE_TOOLS = /^mcp__document360__d360_(create_article|update_article|fork_article|publish_article)$/;
74
- function stripFrontmatter(md) {
75
- const m = md.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n/);
76
- return m ? md.slice(m[0].length) : md;
77
- }
78
- const ARTICLE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
79
- /** Animated "working" line: spinner + rotating verb + live timer + ~token estimate. */
80
- function Working({ startTime, chars }) {
81
- const [tick, setTick] = useState(0);
82
- useEffect(() => {
83
- const id = setInterval(() => setTick(t => t + 1), 120);
84
- return () => clearInterval(id);
85
- }, []);
86
- const frame = SPINNER[tick % SPINNER.length];
87
- const word = GERUNDS[Math.floor(tick / 16) % GERUNDS.length];
88
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
89
- const tokens = Math.round(chars / 4);
90
- return (_jsxs(Box, { children: [_jsx(Text, { color: "cyan", children: ` ${frame} ${word}… ` }), _jsx(Text, { color: "gray", children: `(${elapsed}s · ~${tokens} tokens · ctrl+c to stop)` })] }));
91
- }
92
- export function App({ cwd, auth, profileName, version }) {
93
- const { exit } = useApp();
94
- const [live, setLive] = useState(null);
95
- const [input, setInput] = useState('');
96
- const [busy, setBusy] = useState(false);
97
- /** Ghost-text suggestion for the next prompt (Claude Code-style: tab to accept). */
98
- const [ghost, setGhost] = useState(null);
99
- /** Bumped on every turn/accept — late suggestion responses for old turns are dropped. */
100
- const ghostSeqRef = useRef(0);
101
- // The placeholder is a first-run affordance — once a prompt has been sent the input
102
- // is just a cursor (matches Claude Code). After /clear it returns.
103
- const [started, setStarted] = useState(false);
104
- const [turnChars, setTurnChars] = useState(0);
105
- const turnStartRef = useRef(0);
106
- const [paletteIndex, setPaletteIndex] = useState(0);
107
- const [historyIndex, setHistoryIndex] = useState(null);
108
- /** The transcript model. Items are printed (markdown-rendered to ANSI) through Ink's
109
- console patch the moment they're pushed — console.log inside an Ink app atomically
110
- erases the dynamic frame, writes, and repaints it. Kept here so the WHOLE
111
- transcript can be re-rendered at the new width on resize (which <Static> cannot
112
- do safely — see .claude/docs/gotchas-writer-tui.md). */
113
- const itemsRef = useRef([]);
114
- // LAZY init — `useRef(createSession(...))` would call createSession on EVERY render
115
- // (useRef keeps only the first value but still evaluates the argument each time), and
116
- // createSession spawns an Agent SDK subprocess → a new subprocess per keystroke →
117
- // spawn storm + heap OOM crash. Create it exactly once.
118
- const sessionRef = useRef(null);
119
- if (sessionRef.current === null) {
120
- sessionRef.current = createSession({ cwd, profileName, allowProdWrites: false });
121
- }
122
- const trackerRef = useRef({ uuid: null, firstPrompt: null, titleFired: false });
123
- /** tool_use ids whose header we rendered (with their call details, for result
124
- pairing + article-link extraction) — the rest stay hidden. */
125
- const shownToolsRef = useRef(new Map());
126
- const allowProdRef = useRef(false);
127
- const historyRef = useRef([]);
128
- const { stdout } = useStdout();
129
- const [, setResizeTick] = useState(0);
130
- // Cols AND rows: height-only resizes also reflow under conpty and can strand
131
- // dynamic-frame fragments, so they get a replay too (pty-harness verified).
132
- const lastSizeRef = useRef(`${stdout.columns ?? 80}x${stdout.rows ?? 24}`);
133
- const transcriptWidth = useCallback(() => Math.max(20, (stdout.columns ?? 80) - 1), [stdout]);
134
- /** Append + print one transcript item (markdown-rendered, via Ink's console patch). */
135
- const push = useCallback((item) => {
136
- itemsRef.current.push(item);
137
- console.log(renderItem(item, transcriptWidth()));
138
- }, [transcriptWidth]);
139
- // Print the banner once on mount (it's a transcript item like everything else).
140
- useEffect(() => {
141
- push({ kind: 'banner', info: computeBanner(cwd, version, auth, profileName) });
142
- // eslint-disable-next-line react-hooks/exhaustive-deps
143
- }, []);
144
- // Resize redraw (debounced; only when COLUMNS changed — height changes don't rewrap).
145
- // The terminal rewraps old lines on narrow-resize, so nothing already printed can be
146
- // trusted; we erase the viewport and replay the whole transcript at the new width.
147
- // Hardening (SNAG-0026: mid-drag settles tore lines while conpty reflowed):
148
- // - 400ms debounce: a direction-change pause in a back-and-forth drag is not
149
- // "settled" — one replay at the end, not several along the way.
150
- // - home BEFORE erase (\x1b[H\x1b[2J — canonical cls order conpty handles).
151
- // - synchronized output (\x1b[?2026h/l): WT applies clear+replay as ONE frame.
152
- // The whole write goes THROUGH the console patch (serialized with Ink's frame
153
- // erase/repaint) — never write raw escapes behind Ink's back, and never remount
154
- // <Static> (see .claude/docs/gotchas-writer-tui.md for the s-0024 incident).
155
- useEffect(() => {
156
- let timer = null;
157
- let replayTimer = null;
158
- const onResize = () => {
159
- if (timer)
160
- clearTimeout(timer);
161
- timer = setTimeout(() => {
162
- timer = null;
163
- const size = `${stdout.columns ?? 80}x${stdout.rows ?? 24}`;
164
- if (size === lastSizeRef.current)
165
- return;
166
- lastSizeRef.current = size;
167
- // ORDER MATTERS (pty-harness verified): first re-render the dynamic region at
168
- // the new width; only after Ink painted that frame do the replay. Replaying
169
- // first leaves Ink writing an OLD-width frame after our clear — its rewrapped
170
- // lines defeat the next erase and strand the frame's top border on screen.
171
- setResizeTick(t => t + 1);
172
- if (replayTimer)
173
- clearTimeout(replayTimer);
174
- replayTimer = setTimeout(() => {
175
- replayTimer = null;
176
- console.log('\x1b[?2026h\x1b[H\x1b[2J' + renderTranscript(itemsRef.current, transcriptWidth()) + '\x1b[?2026l');
177
- }, 80);
178
- }, 400);
179
- };
180
- stdout.on('resize', onResize);
181
- return () => {
182
- if (timer)
183
- clearTimeout(timer);
184
- if (replayTimer)
185
- clearTimeout(replayTimer);
186
- stdout.off('resize', onResize);
187
- };
188
- }, [stdout, transcriptWidth]);
189
- // CRITICAL: cap the rendered width to columns-1. A full-terminal-width element (the
190
- // input border) sits exactly at the wrap boundary and wraps by one column; Ink then
191
- // erases by a line count that's short, leaving the overflow behind — so every render
192
- // STACKS a fresh frame instead of replacing it (unbounded growth → lag → freeze, and
193
- // the resize artifacts). One free column means nothing wraps and erase is exact.
194
- const width = Math.max(20, (stdout.columns ?? 80) - 1);
195
- const status = useMemo(() => statusLine(cwd, profileName), [cwd, profileName]);
196
- const palette = filterCommands(input);
197
- const paletteOpen = palette.length > 0 && !busy;
198
- // Cap the live area well under typical terminal heights; the held-back text appears
199
- // markdown-rendered when its paragraph (or code fence) completes.
200
- const liveView = live !== null ? liveTail(live, 8, width) : null;
201
- const restartSession = useCallback((resume) => {
202
- sessionRef.current?.close();
203
- sessionRef.current = createSession({ cwd, resume, profileName, allowProdWrites: allowProdRef.current });
204
- if (!resume)
205
- trackerRef.current = { uuid: null, firstPrompt: null, titleFired: false };
206
- }, [cwd, profileName]);
207
- /** After a successful article write, surface where to see the outcome (option C). */
208
- const pushArticleLinks = useCallback((toolName, input, output) => {
209
- if (!ARTICLE_WRITE_TOOLS.test(toolName))
210
- return;
211
- try {
212
- const active = resolveActiveProfile(cwd, profileName);
213
- const projectId = (typeof input['project_id'] === 'string' && input['project_id']) || active.project.projectId;
214
- const articleId = extractArticleId(input, output);
215
- const lines = [];
216
- const publicUrl = extractPublicUrl(output);
217
- if (toolName.endsWith('publish_article') && publicUrl)
218
- lines.push(`Live: ${publicUrl}`);
219
- if (articleId && projectId) {
220
- lines.push(`Preview in Document360: ${articleEditorUrl(active.connection.portalUrl, projectId, articleId, active.project.languageCode ?? 'en')}`);
221
- }
222
- if (lines.length > 0)
223
- push({ kind: 'link', lines });
224
- }
225
- catch {
226
- /* no/invalid profile — skip the links */
227
- }
228
- }, [cwd, profileName, push]);
229
- const runPrompt = useCallback(async (text) => {
230
- setStarted(true);
231
- setGhost(null);
232
- const ghostSeq = ++ghostSeqRef.current;
233
- push({ kind: 'user', text });
234
- const tracker = trackerRef.current;
235
- if (!tracker.firstPrompt)
236
- tracker.firstPrompt = text;
237
- turnStartRef.current = Date.now();
238
- setTurnChars(0);
239
- setBusy(true);
240
- shownToolsRef.current.clear();
241
- let turnText = ''; // all assistant text this turn (for the next-action suggestion)
242
- let buffer = '';
243
- // Throttle live repaints to ~16fps: a repaint per token delta flickers and
244
- // multiplies the chances of erase/rewrap races; batching costs nothing visible.
245
- let liveTimer = null;
246
- const scheduleLive = () => {
247
- if (liveTimer)
248
- return;
249
- liveTimer = setTimeout(() => {
250
- liveTimer = null;
251
- setLive(buffer.length > 0 ? buffer : null);
252
- }, 60);
253
- };
254
- const clearLive = () => {
255
- if (liveTimer)
256
- clearTimeout(liveTimer);
257
- liveTimer = null;
258
- setLive(null);
259
- };
260
- const flush = () => {
261
- if (buffer.trim()) {
262
- const t = buffer.trimEnd();
263
- push({ kind: 'assistant', text: t });
264
- }
265
- buffer = '';
266
- clearLive();
267
- };
268
- try {
269
- for await (const ev of sessionRef.current.send(text)) {
270
- if (ev.type === 'session') {
271
- if (!tracker.uuid) {
272
- tracker.uuid = ev.sessionId;
273
- const now = new Date().toISOString();
274
- upsertSession({ uuid: ev.sessionId, name: slugify(tracker.firstPrompt ?? 'session'), renamed: false, titled: false, cwd, firstPrompt: tracker.firstPrompt ?? '', createdAt: now, updatedAt: now });
275
- }
276
- }
277
- else if (ev.type === 'text') {
278
- buffer += ev.delta;
279
- turnText += ev.delta;
280
- // Flush completed paragraphs into the (markdown-rendered) transcript as they
281
- // stream — text that scrolls out of the viewport can't be repainted later.
282
- const cut = streamCut(buffer);
283
- if (cut > 0) {
284
- const chunk = buffer.slice(0, cut).trimEnd();
285
- if (chunk)
286
- push({ kind: 'assistant', text: chunk });
287
- buffer = buffer.slice(cut);
288
- }
289
- scheduleLive();
290
- setTurnChars(c => c + ev.delta.length);
291
- }
292
- else if (ev.type === 'tool') {
293
- const header = formatToolUse(ev.name, ev.input);
294
- if (header) {
295
- flush();
296
- push({ kind: 'tool', title: header.title, sep: header.sep, arg: header.arg });
297
- shownToolsRef.current.set(ev.id, { name: ev.name, input: ev.input });
298
- }
299
- }
300
- else if (ev.type === 'tool_result') {
301
- // Only render results whose call we showed (hidden tools stay hidden).
302
- const pending = shownToolsRef.current.get(ev.id);
303
- if (pending) {
304
- shownToolsRef.current.delete(ev.id);
305
- flush();
306
- const view = formatToolResult(ev.output);
307
- push({ kind: 'tool-result', lines: view.lines, hidden: view.hidden, isError: ev.isError });
308
- if (!ev.isError)
309
- pushArticleLinks(pending.name, pending.input, ev.output);
310
- }
311
- }
312
- else if (ev.type === 'result') {
313
- flush();
314
- push({ kind: 'done', seconds: Math.round((Date.now() - turnStartRef.current) / 1000), tokens: ev.outputTokens, ok: ev.ok });
315
- // Fire-and-forget ghost-text suggestion; drop it if a newer turn started.
316
- if (ev.ok && turnText.trim()) {
317
- void suggestNextAction(text, turnText, cwd)
318
- .then(s => {
319
- if (s && ghostSeqRef.current === ghostSeq)
320
- setGhost(s);
321
- })
322
- .catch(() => { });
323
- }
324
- if (tracker.uuid) {
325
- touchSession(tracker.uuid);
326
- if (!tracker.titleFired) {
327
- tracker.titleFired = true;
328
- const uuid = tracker.uuid;
329
- const fp = tracker.firstPrompt;
330
- if (fp)
331
- void generateTitle(fp, cwd).then(t => t && setTitle(uuid, t)).catch(() => { });
332
- }
333
- }
334
- }
335
- else if (ev.type === 'error') {
336
- flush();
337
- push({ kind: 'note', text: `agent error: ${ev.message}`, tone: 'error' });
338
- }
339
- }
340
- }
341
- finally {
342
- setBusy(false);
343
- clearLive();
344
- }
345
- }, [cwd, push, pushArticleLinks]);
346
- const handleCommand = useCallback(async (line) => {
347
- const parts = line.slice(1).trim().split(/\s+/);
348
- const name = (parts[0] ?? '').toLowerCase();
349
- const args = parts.slice(1);
350
- switch (name) {
351
- case 'help':
352
- push({ kind: 'note', tone: 'info', text: COMMANDS.map(c => ` ${c.usage.padEnd(22)} ${c.desc}`).join('\n') });
353
- return;
354
- case 'exit':
355
- case 'quit':
356
- sessionRef.current?.close();
357
- exit();
358
- return;
359
- case 'clear':
360
- restartSession();
361
- itemsRef.current = []; // fresh transcript model (what's on screen stays in scrollback)
362
- setStarted(false);
363
- setGhost(null);
364
- ghostSeqRef.current++;
365
- push({ kind: 'note', tone: 'info', text: 'Conversation reset (the previous session is still resumable via /resume).' });
366
- return;
367
- case 'allow-prod': {
368
- let prod = false;
369
- try {
370
- prod = resolveActiveProfile(cwd, profileName).production;
371
- }
372
- catch { /* none */ }
373
- if (!prod) {
374
- push({ kind: 'note', tone: 'info', text: 'Current profile is not production — writes are already allowed.' });
375
- return;
376
- }
377
- allowProdRef.current = true;
378
- restartSession();
379
- push({ kind: 'note', tone: 'warn', text: '⚠ Production writes authorized for this session.' });
380
- return;
381
- }
382
- case 'rename': {
383
- const newName = args.join(' ').trim();
384
- if (!newName) {
385
- push({ kind: 'note', tone: 'error', text: 'Usage: /rename <name>' });
386
- return;
387
- }
388
- const uuid = trackerRef.current.uuid;
389
- if (!uuid) {
390
- push({ kind: 'note', tone: 'error', text: 'Send a message first — sessions save once the agent replies.' });
391
- return;
392
- }
393
- renameSession(uuid, newName);
394
- push({ kind: 'note', tone: 'ok', text: `Session renamed to "${newName}".` });
395
- return;
396
- }
397
- case 'profile': {
398
- const target = args[0];
399
- if (!target) {
400
- const cfg = readProjectConfig(cwd);
401
- const lines = Object.entries(cfg?.profiles ?? {}).map(([n, p]) => ` ${n === cfg?.defaultProfile ? '●' : ' '} ${n}${p.production ? ' ⚠ PRODUCTION' : ''}`);
402
- push({ kind: 'note', tone: 'info', text: lines.length ? lines.join('\n') : 'No profiles. Run d360-writer init.' });
403
- return;
404
- }
405
- const cfg = readProjectConfig(cwd);
406
- if (!cfg?.profiles?.[target]) {
407
- push({ kind: 'note', tone: 'error', text: `Unknown profile "${target}".` });
408
- return;
409
- }
410
- cfg.defaultProfile = target;
411
- writeProjectConfig(cfg, cwd);
412
- restartSession();
413
- push({ kind: 'note', tone: 'ok', text: `Switched to profile "${target}" (agent restarted).` });
414
- return;
415
- }
416
- case 'resume': {
417
- const target = args.join(' ').trim();
418
- const sessions = listSessions(cwd).filter(s => s.uuid !== trackerRef.current.uuid);
419
- if (!target) {
420
- if (!sessions.length) {
421
- push({ kind: 'note', tone: 'info', text: 'No saved sessions for this repo yet.' });
422
- return;
423
- }
424
- const lines = sessions.slice(0, 15).map(s => ` ${s.name} — ${s.firstPrompt.slice(0, 60)}`);
425
- push({ kind: 'note', tone: 'info', text: `Resume with /resume <name>:\n${lines.join('\n')}` });
426
- return;
427
- }
428
- const rec = findByName(cwd, target);
429
- if (!rec) {
430
- push({ kind: 'note', tone: 'error', text: `No session matches "${target}".` });
431
- return;
432
- }
433
- restartSession(rec.uuid);
434
- trackerRef.current = { uuid: rec.uuid, firstPrompt: rec.firstPrompt, titleFired: true };
435
- touchSession(rec.uuid);
436
- push({ kind: 'note', tone: 'ok', text: `Resumed "${rec.name}".` });
437
- return;
438
- }
439
- case 'workspace': {
440
- const target = args.join(' ').trim();
441
- let data;
442
- try {
443
- data = await fetchWorkspaces(cwd, profileName);
444
- }
445
- catch (e) {
446
- push({ kind: 'note', tone: 'error', text: `Could not list workspaces: ${e.message}` });
447
- return;
448
- }
449
- if (!target) {
450
- const lines = data.workspaces.map(w => ` ${w.id === data.current ? '●' : ' '} ${w.name ?? w.id}${w.workspace_type ? ` · ${w.workspace_type}` : ''}`);
451
- push({ kind: 'note', tone: 'info', text: `Workspaces (switch with /workspace <name>):\n${lines.join('\n')}` });
452
- return;
453
- }
454
- const w = matchWorkspace(data.workspaces, target);
455
- if (!w) {
456
- push({ kind: 'note', tone: 'error', text: `No workspace matches "${target}".` });
457
- return;
458
- }
459
- setWorkspace(cwd, data.profile, data.projectId, w.id);
460
- push({ kind: 'note', tone: 'ok', text: `Switched to workspace "${w.name ?? w.id}" (agent restarted).` });
461
- restartSession();
462
- return;
463
- }
464
- case 'preview': {
465
- const target = args.join(' ').trim();
466
- if (!target) {
467
- push({ kind: 'note', tone: 'error', text: 'Usage: /preview <path-to.md | article-id>' });
468
- return;
469
- }
470
- const asPath = isAbsolute(target) ? target : join(cwd, target);
471
- if (existsSync(asPath)) {
472
- try {
473
- push({ kind: 'preview', name: basename(asPath), text: stripFrontmatter(readFileSync(asPath, 'utf8')) });
474
- }
475
- catch (e) {
476
- push({ kind: 'note', tone: 'error', text: `Could not read ${asPath}: ${e.message}` });
477
- }
478
- return;
479
- }
480
- if (ARTICLE_ID_RE.test(target)) {
481
- try {
482
- const active = resolveActiveProfile(cwd, profileName);
483
- const ctx = { profile: active.name, connection: active.connection };
484
- const projectId = active.project.projectId ?? resolveProjectId(ctx);
485
- const article = await getArticle(ctx, projectId, target);
486
- push({ kind: 'preview', name: article.title ?? target, text: article.content ?? '*(article has no content)*' });
487
- }
488
- catch (e) {
489
- push({ kind: 'note', tone: 'error', text: `Could not fetch article: ${e.message}` });
490
- }
491
- return;
492
- }
493
- push({ kind: 'note', tone: 'error', text: `"${target}" is neither a file (relative to ${cwd}) nor an article id.` });
494
- return;
495
- }
496
- case 'publish':
497
- case 'audit':
498
- case 'screenshot': {
499
- // These build a prompt and forward it to the agent. Validate args so their
500
- // console.log usage-error path never fires inside Ink.
501
- if (name === 'publish' && !args[0]) {
502
- push({ kind: 'note', tone: 'error', text: 'Usage: /publish <article-path>' });
503
- return;
504
- }
505
- if (name === 'screenshot' && !args[0]) {
506
- push({ kind: 'note', tone: 'error', text: 'Usage: /screenshot <id>' });
507
- return;
508
- }
509
- const fn = (name === 'publish' ? publishCommand : name === 'audit' ? auditCommand : screenshotCommand);
510
- const result = await fn(args, undefined);
511
- if (result.kind === 'forward-to-agent' && result.prompt)
512
- await runPrompt(result.prompt);
513
- return;
514
- }
515
- default:
516
- push({ kind: 'note', tone: 'error', text: `Unknown command: /${name} — type /help.` });
517
- }
518
- }, [cwd, exit, profileName, push, restartSession, runPrompt]);
519
- const submit = useCallback(() => {
520
- const text = input.trim();
521
- setInput('');
522
- setHistoryIndex(null);
523
- setPaletteIndex(0);
524
- if (!text)
525
- return;
526
- historyRef.current.push(text);
527
- if (text.startsWith('/'))
528
- void handleCommand(text);
529
- else
530
- void runPrompt(text);
531
- }, [input, handleCommand, runPrompt]);
532
- useInput((char, key) => {
533
- if (key.ctrl && char === 'c') {
534
- sessionRef.current?.close();
535
- exit();
536
- return;
537
- }
538
- if (busy)
539
- return; // ignore input while the agent is working
540
- // Tab accepts the ghost suggestion (palette is closed when input is empty).
541
- if (key.tab && !input && ghost) {
542
- setInput(ghost);
543
- setGhost(null);
544
- ghostSeqRef.current++;
545
- return;
546
- }
547
- if (paletteOpen) {
548
- if (key.upArrow) {
549
- setPaletteIndex(i => Math.max(0, i - 1));
550
- return;
551
- }
552
- if (key.downArrow) {
553
- setPaletteIndex(i => Math.min(palette.length - 1, i + 1));
554
- return;
555
- }
556
- if (key.tab) {
557
- setInput('/' + (palette[paletteIndex]?.name ?? '') + ' ');
558
- setPaletteIndex(0);
559
- return;
560
- }
561
- }
562
- else {
563
- if (key.upArrow) {
564
- const h = historyRef.current;
565
- if (!h.length)
566
- return;
567
- const idx = historyIndex === null ? h.length - 1 : Math.max(0, historyIndex - 1);
568
- setHistoryIndex(idx);
569
- setInput(h[idx] ?? '');
570
- return;
571
- }
572
- if (key.downArrow) {
573
- const h = historyRef.current;
574
- if (historyIndex === null)
575
- return;
576
- const idx = historyIndex + 1;
577
- if (idx >= h.length) {
578
- setHistoryIndex(null);
579
- setInput('');
580
- }
581
- else {
582
- setHistoryIndex(idx);
583
- setInput(h[idx] ?? '');
584
- }
585
- return;
586
- }
587
- }
588
- if (key.return) {
589
- submit();
590
- return;
591
- }
592
- if (key.backspace || key.delete) {
593
- setInput(s => s.slice(0, -1));
594
- return;
595
- }
596
- if (key.escape) {
597
- setInput('');
598
- setPaletteIndex(0);
599
- setGhost(null);
600
- return;
601
- }
602
- if (char && !key.ctrl && !key.meta)
603
- setInput(s => s + char);
604
- });
605
- return (_jsxs(Box, { flexDirection: "column", width: width, children: [liveView !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [liveView.truncated && _jsx(Text, { dimColor: true, children: '…' }), _jsx(Text, { children: liveView.text })] })), busy && _jsx(Working, { startTime: turnStartRef.current, chars: turnChars }), paletteOpen && (_jsx(Box, { flexDirection: "column", marginTop: 1, children: palette.map((c, i) => (_jsxs(Text, { color: i === paletteIndex ? 'cyan' : 'gray', children: [i === paletteIndex ? '❯ ' : ' ', c.usage.padEnd(22), " ", c.desc] }, c.name))) })), _jsxs(Box, { borderStyle: "round", borderColor: status.prod ? 'yellow' : 'cyan', borderTop: true, borderBottom: true, borderLeft: false, borderRight: false, marginTop: paletteOpen ? 0 : 1, children: [_jsx(Text, { color: "cyan", children: '> ' }), input ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: input }), _jsx(Cursor, { ch: " " })] })) : ghost && !busy ? (_jsxs(_Fragment, { children: [_jsx(Cursor, { ch: ghost[0], dim: true }), _jsx(Text, { color: "gray", children: ghost.slice(1) }), _jsx(Text, { dimColor: true, children: ' (tab)' })] })) : started ? (_jsx(Cursor, { ch: " " })) : (_jsxs(_Fragment, { children: [_jsx(Cursor, { ch: PLACEHOLDER[0], dim: true }), _jsx(Text, { color: "gray", children: PLACEHOLDER.slice(1) })] }))] }), _jsx(Box, { paddingX: 1, children: _jsxs(Text, { color: "gray", children: [status.prod ? '⚠ PRODUCTION · ' : '', '/help · ↑ history · ctrl+c exit'] }) })] }));
606
- }
607
- //# sourceMappingURL=App.js.map