@agent-link/server 0.1.185 → 0.1.187

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-link/server",
3
- "version": "0.1.185",
3
+ "version": "0.1.187",
4
4
  "description": "AgentLink relay server",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -127,6 +127,10 @@ const App = {
127
127
  const currentConversationId = ref(crypto.randomUUID()); // currently visible conversation
128
128
  const processingConversations = ref({}); // conversationId → boolean
129
129
 
130
+ // Plan mode state
131
+ const planMode = ref(false);
132
+ const pendingPlanMode = ref(null); // 'enter' | 'exit' | null — set while toggle is in flight
133
+
130
134
  // File browser state
131
135
  const filePanelOpen = ref(false);
132
136
  const filePanelWidth = ref(parseInt(localStorage.getItem('agentlink-file-panel-width'), 10) || 280);
@@ -240,6 +244,7 @@ const App = {
240
244
  messageIdCounter: streaming.getMessageIdCounter(),
241
245
  queuedMessages: queuedMessages.value,
242
246
  usageStats: usageStats.value,
247
+ planMode: planMode.value,
243
248
  };
244
249
  }
245
250
 
@@ -260,6 +265,7 @@ const App = {
260
265
  _restoreToolMsgMap(cached.toolMsgMap || new Map());
261
266
  queuedMessages.value = cached.queuedMessages || [];
262
267
  usageStats.value = cached.usageStats || null;
268
+ planMode.value = cached.planMode || false;
263
269
  } else {
264
270
  // New blank conversation
265
271
  messages.value = [];
@@ -275,6 +281,7 @@ const App = {
275
281
  _clearToolMsgMap();
276
282
  queuedMessages.value = [];
277
283
  usageStats.value = null;
284
+ planMode.value = false;
278
285
  }
279
286
 
280
287
  currentConversationId.value = newConvId;
@@ -364,6 +371,8 @@ const App = {
364
371
  memoryFiles, memoryDir, memoryLoading, memoryEditing, memoryEditContent, memorySaving, memoryPanelOpen,
365
372
  // Side question (/btw)
366
373
  btwState, btwPending,
374
+ // Plan mode
375
+ setPlanMode,
367
376
  // i18n
368
377
  t,
369
378
  });
@@ -566,6 +575,28 @@ const App = {
566
575
  if (idx !== -1) queuedMessages.value.splice(idx, 1);
567
576
  }
568
577
 
578
+ // ── Plan mode ──
579
+ function togglePlanMode() {
580
+ if (isProcessing.value) return;
581
+ const newMode = !planMode.value;
582
+ pendingPlanMode.value = newMode ? 'enter' : 'exit';
583
+ isProcessing.value = true;
584
+ if (currentConversationId.value) {
585
+ processingConversations.value[currentConversationId.value] = true;
586
+ }
587
+ const instruction = newMode ? 'Enter plan mode now.' : 'Exit plan mode now.';
588
+ messages.value.push({
589
+ id: streaming.nextId(), role: 'user', content: instruction,
590
+ status: 'sent', timestamp: new Date(),
591
+ });
592
+ wsSend({ type: 'set_plan_mode', enabled: newMode, conversationId: currentConversationId.value, claudeSessionId: currentClaudeSessionId.value });
593
+ nextTick(() => scrollToBottom());
594
+ }
595
+ function setPlanMode(enabled) {
596
+ planMode.value = enabled;
597
+ pendingPlanMode.value = null;
598
+ }
599
+
569
600
  function selectSlashCommand(cmd) {
570
601
  slashMenuOpen.value = false;
571
602
  if (cmd.isPrefix) {
@@ -706,6 +737,8 @@ const App = {
706
737
  inputText, isProcessing, isCompacting, canSend, hasInput, hasStreamingMessage, inputRef, queuedMessages, usageStats,
707
738
  slashMenuVisible, filteredSlashCommands, slashMenuIndex, slashMenuOpen, selectSlashCommand, openSlashMenu,
708
739
  sendMessage, handleKeydown, cancelExecution, removeQueuedMessage, onMessageListScroll,
740
+ // Plan mode
741
+ planMode, pendingPlanMode, togglePlanMode,
709
742
  // Side question (/btw)
710
743
  btwState, btwPending, dismissBtw, renderMarkdown,
711
744
  getRenderedContent, copyMessage, toggleTool,
@@ -821,7 +854,7 @@ const App = {
821
854
  },
822
855
  workdirMenuCopyPath() {
823
856
  workdirMenuOpen.value = false;
824
- navigator.clipboard.writeText(workDir.value);
857
+ fileBrowser.copyToClipboard(workDir.value);
825
858
  },
826
859
  // Memory management
827
860
  memoryPanelOpen, memoryFiles, memoryDir, memoryLoading,
@@ -2029,6 +2062,12 @@ const App = {
2029
2062
  <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
2030
2063
  </div>
2031
2064
  </div>
2065
+ <!-- Plan mode switch indicator -->
2066
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2067
+ <span class="plan-mode-divider-line"></span>
2068
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2069
+ <span class="plan-mode-divider-line"></span>
2070
+ </div>
2032
2071
  <!-- Agent tool use -->
2033
2072
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2034
2073
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
@@ -2087,6 +2126,11 @@ const App = {
2087
2126
  <div class="message-content markdown-body" v-html="getRenderedContent(msg)"></div>
2088
2127
  </div>
2089
2128
  </div>
2129
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2130
+ <span class="plan-mode-divider-line"></span>
2131
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2132
+ <span class="plan-mode-divider-line"></span>
2133
+ </div>
2090
2134
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2091
2135
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
2092
2136
  <span class="tool-icon" v-html="getToolIcon(msg.toolName)"></span>
@@ -2411,6 +2455,13 @@ const App = {
2411
2455
  </div>
2412
2456
  </div>
2413
2457
 
2458
+ <!-- Plan mode switch indicator -->
2459
+ <div v-else-if="msg.role === 'tool' && (msg.toolName === 'EnterPlanMode' || msg.toolName === 'ExitPlanMode')" class="plan-mode-divider">
2460
+ <span class="plan-mode-divider-line"></span>
2461
+ <span class="plan-mode-divider-text">{{ msg.toolName === 'EnterPlanMode' ? t('tool.enteredPlanMode') : t('tool.exitedPlanMode') }}</span>
2462
+ <span class="plan-mode-divider-line"></span>
2463
+ </div>
2464
+
2414
2465
  <!-- Tool use block (collapsible) -->
2415
2466
  <div v-else-if="msg.role === 'tool'" class="tool-line-wrapper">
2416
2467
  <div :class="['tool-line', { completed: msg.hasResult, running: !msg.hasResult }]" @click="toggleTool(msg)">
@@ -2500,6 +2551,7 @@ const App = {
2500
2551
 
2501
2552
  <div v-if="isProcessing && !hasStreamingMessage" class="typing-indicator">
2502
2553
  <span></span><span></span><span></span>
2554
+ <span v-if="pendingPlanMode" class="typing-label">{{ pendingPlanMode === 'enter' ? t('tool.enteringPlanMode') : t('tool.exitingPlanMode') }}</span>
2503
2555
  </div>
2504
2556
  </div>
2505
2557
  </div>
@@ -2563,7 +2615,7 @@ const App = {
2563
2615
  </div>
2564
2616
  </div>
2565
2617
  <div
2566
- :class="['input-card', { 'drag-over': dragOver }]"
2618
+ :class="['input-card', { 'drag-over': dragOver, 'plan-mode': planMode }]"
2567
2619
  @dragover="handleDragOver"
2568
2620
  @dragleave="handleDragLeave"
2569
2621
  @drop="handleDrop"
@@ -2599,6 +2651,10 @@ const App = {
2599
2651
  <button class="slash-btn" @click="openSlashMenu" :disabled="status !== 'Connected'" :title="t('input.slashCommands')">
2600
2652
  <svg viewBox="0 0 24 24" width="16" height="16"><path fill="currentColor" d="M7 21 11 3h2L9 21H7Z"/></svg>
2601
2653
  </button>
2654
+ <button :class="['plan-mode-btn', { active: planMode }]" @click="togglePlanMode" :disabled="isProcessing" :title="planMode ? 'Switch to Normal Mode' : 'Switch to Plan Mode'">
2655
+ <svg viewBox="0 0 24 24" width="12" height="12"><rect x="6" y="4" width="4" height="16" rx="1" fill="currentColor"/><rect x="14" y="4" width="4" height="16" rx="1" fill="currentColor"/></svg>
2656
+ Plan
2657
+ </button>
2602
2658
  </div>
2603
2659
  <button v-if="isProcessing && !hasInput" @click="cancelExecution" class="send-btn stop-btn" :title="t('input.stopGeneration')">
2604
2660
  <svg viewBox="0 0 24 24" width="14" height="14"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor"/></svg>
@@ -271,7 +271,7 @@
271
271
  padding: 0.5rem 0.9rem;
272
272
  }
273
273
 
274
- .typing-indicator span {
274
+ .typing-indicator span:not(.typing-label) {
275
275
  width: 6px;
276
276
  height: 6px;
277
277
  border-radius: 50%;
@@ -279,11 +279,11 @@
279
279
  animation: typing 1.2s infinite ease-in-out;
280
280
  }
281
281
 
282
- .typing-indicator span:nth-child(2) {
282
+ .typing-indicator span:not(.typing-label):nth-child(2) {
283
283
  animation-delay: 0.2s;
284
284
  }
285
285
 
286
- .typing-indicator span:nth-child(3) {
286
+ .typing-indicator span:not(.typing-label):nth-child(3) {
287
287
  animation-delay: 0.4s;
288
288
  }
289
289
 
@@ -292,6 +292,18 @@
292
292
  30% { opacity: 1; transform: scale(1); }
293
293
  }
294
294
 
295
+ .typing-label {
296
+ font-size: 0.78rem;
297
+ color: var(--text-secondary);
298
+ margin-left: 4px;
299
+ white-space: nowrap;
300
+ animation: none;
301
+ width: auto;
302
+ height: auto;
303
+ border-radius: 0;
304
+ background: none;
305
+ }
306
+
295
307
  /* ── Context compaction inline message ── */
296
308
  .compact-msg {
297
309
  display: inline-flex;
package/web/css/base.css CHANGED
@@ -30,6 +30,8 @@
30
30
  --border: #353535;
31
31
  --code-bg: #1a1a1a;
32
32
  --code-header-bg: #222222;
33
+ --plan-mode: #d4a24c;
34
+ --plan-mode-bg: rgba(212, 162, 76, 0.1);
33
35
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif;
34
36
  }
35
37
 
@@ -48,6 +50,8 @@
48
50
  --border: #d1d5db;
49
51
  --code-bg: #f1f3f5;
50
52
  --code-header-bg: #e9ecef;
53
+ --plan-mode: #d97706;
54
+ --plan-mode-bg: rgba(217, 119, 6, 0.08);
51
55
  }
52
56
 
53
57
  html {
package/web/css/chat.css CHANGED
@@ -172,3 +172,5 @@
172
172
  padding: 0.2rem 0;
173
173
  }
174
174
 
175
+
176
+
package/web/css/input.css CHANGED
@@ -614,6 +614,56 @@
614
614
  color: var(--text-secondary);
615
615
  }
616
616
 
617
+ /* ── Plan Mode toggle button ── */
618
+ .plan-mode-btn {
619
+ background: none;
620
+ border: 1px solid var(--border);
621
+ color: var(--text-secondary);
622
+ cursor: pointer;
623
+ display: flex;
624
+ align-items: center;
625
+ gap: 4px;
626
+ height: 28px;
627
+ padding: 0 8px;
628
+ border-radius: 6px;
629
+ font-size: 0.75rem;
630
+ font-weight: 600;
631
+ letter-spacing: 0.02em;
632
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
633
+ white-space: nowrap;
634
+ }
635
+
636
+ .plan-mode-btn:hover:not(:disabled) {
637
+ color: var(--text-primary);
638
+ background: var(--bg-tertiary);
639
+ border-color: var(--text-secondary);
640
+ }
641
+
642
+ .plan-mode-btn.active {
643
+ color: var(--plan-mode);
644
+ background: var(--plan-mode-bg);
645
+ border-color: var(--plan-mode);
646
+ }
647
+
648
+ .plan-mode-btn.active:hover:not(:disabled) {
649
+ background: rgba(212, 162, 76, 0.18);
650
+ }
651
+
652
+ .plan-mode-btn:disabled {
653
+ opacity: 0.35;
654
+ cursor: not-allowed;
655
+ }
656
+
657
+ .plan-mode-btn svg {
658
+ flex-shrink: 0;
659
+ }
660
+
661
+
662
+ /* ── Input card plan mode accent ── */
663
+ .input-card.plan-mode {
664
+ border-top: 2px solid var(--plan-mode);
665
+ }
666
+
617
667
  /* ── Sidebar backdrop (mobile overlay) ── */
618
668
  .sidebar-backdrop {
619
669
  display: none;
package/web/css/tools.css CHANGED
@@ -304,3 +304,24 @@
304
304
  margin: 0.75rem 0;
305
305
  }
306
306
 
307
+ /* ── Plan mode divider ── */
308
+ .plan-mode-divider {
309
+ display: flex;
310
+ align-items: center;
311
+ gap: 12px;
312
+ margin: 8px 0;
313
+ }
314
+
315
+ .plan-mode-divider-line {
316
+ flex: 1;
317
+ height: 1px;
318
+ background: var(--border);
319
+ }
320
+
321
+ .plan-mode-divider-text {
322
+ font-size: 0.75rem;
323
+ color: var(--text-secondary);
324
+ white-space: nowrap;
325
+ font-weight: 500;
326
+ }
327
+
@@ -245,6 +245,10 @@
245
245
  "tool.inPath": "in {path}",
246
246
  "tool.done": "{done}/{total} done",
247
247
  "tool.replaceAll": "(replace all)",
248
+ "tool.enteredPlanMode": "Entered Plan Mode",
249
+ "tool.exitedPlanMode": "Exited Plan Mode",
250
+ "tool.enteringPlanMode": "Entering Plan Mode...",
251
+ "tool.exitingPlanMode": "Exiting Plan Mode...",
248
252
 
249
253
  "usage.context": "Context",
250
254
  "usage.cost": "Cost",
@@ -245,6 +245,10 @@
245
245
  "tool.inPath": "在 {path}",
246
246
  "tool.done": "{done}/{total} 已完成",
247
247
  "tool.replaceAll": "(全部替换)",
248
+ "tool.enteredPlanMode": "已进入计划模式",
249
+ "tool.exitedPlanMode": "已退出计划模式",
250
+ "tool.enteringPlanMode": "正在进入计划模式...",
251
+ "tool.exitingPlanMode": "正在退出计划模式...",
248
252
 
249
253
  "usage.context": "上下文",
250
254
  "usage.cost": "费用",
@@ -36,6 +36,8 @@ export function createConnection(deps) {
36
36
  memoryFiles, memoryDir, memoryLoading, memoryEditing, memoryEditContent, memorySaving, memoryPanelOpen,
37
37
  // Side question (/btw)
38
38
  btwState, btwPending,
39
+ // Plan mode
40
+ setPlanMode,
39
41
  // i18n
40
42
  t,
41
43
  } = deps;
@@ -547,6 +549,10 @@ export function createConnection(deps) {
547
549
  messages.value = buildHistoryBatch(msg.history, () => streaming.nextId());
548
550
  toolMsgMap.clear();
549
551
  }
552
+ // Detect plan mode from agent-provided flag
553
+ if (msg.planMode != null) {
554
+ if (setPlanMode) setPlanMode(!!msg.planMode);
555
+ }
550
556
  loadingHistory.value = false;
551
557
  // Restore live status from agent (compacting / processing)
552
558
  if (msg.isCompacting) {
@@ -611,6 +617,17 @@ export function createConnection(deps) {
611
617
  btwState.value.done = true;
612
618
  }
613
619
  }
620
+ } else if (msg.type === 'plan_mode_changed') {
621
+ if (setPlanMode) setPlanMode(msg.enabled);
622
+ // For the immediate path (no injected turn), clear isProcessing here
623
+ // because turn_completed will never arrive.
624
+ if (msg.immediate) {
625
+ isProcessing.value = false;
626
+ if (currentConversationId.value) {
627
+ processingConversations.value[currentConversationId.value] = false;
628
+ }
629
+ }
630
+ // For the injected path, turn_completed handles isProcessing naturally.
614
631
  } else if (msg.type === 'workdir_changed') {
615
632
  workdirSwitching.value = false;
616
633
  workDir.value = msg.workDir;
@@ -256,13 +256,31 @@ export function createFileBrowser(deps) {
256
256
  });
257
257
  }
258
258
 
259
+ function copyToClipboard(text) {
260
+ // Use ClipboardItem with explicit text/plain to prevent Chrome (especially
261
+ // mobile) from URL-encoding paths that look like URL schemes (e.g. Q:\...)
262
+ if (navigator.clipboard && window.ClipboardItem) {
263
+ const blob = new Blob([text], { type: 'text/plain' });
264
+ const item = new ClipboardItem({ 'text/plain': blob });
265
+ return navigator.clipboard.write([item]);
266
+ }
267
+ // Fallback for older browsers
268
+ const textarea = document.createElement('textarea');
269
+ textarea.value = text;
270
+ textarea.style.position = 'fixed';
271
+ textarea.style.opacity = '0';
272
+ document.body.appendChild(textarea);
273
+ textarea.select();
274
+ document.execCommand('copy');
275
+ document.body.removeChild(textarea);
276
+ return Promise.resolve();
277
+ }
278
+
259
279
  function copyPath() {
260
280
  const menu = fileContextMenu.value;
261
281
  if (!menu) return;
262
282
  const path = menu.path;
263
- navigator.clipboard.writeText(path).catch(() => {
264
- // Fallback: some browsers block clipboard in non-secure contexts
265
- });
283
+ copyToClipboard(path).catch(() => {});
266
284
  // Brief "Copied!" feedback — store temporarily in menu state
267
285
  fileContextMenu.value = { ...menu, copied: true };
268
286
  setTimeout(() => {
@@ -369,6 +387,7 @@ export function createFileBrowser(deps) {
369
387
  closeContextMenu,
370
388
  askClaudeRead,
371
389
  copyPath,
390
+ copyToClipboard,
372
391
  insertPath,
373
392
  refreshTree,
374
393
  handleDirectoryListing,
@@ -39,11 +39,12 @@ export function createStreaming({ messages, scrollToBottom }) {
39
39
 
40
40
  if (!streamMsg) {
41
41
  const id = ++messageIdCounter;
42
- messages.value.push({
42
+ const newMsg = {
43
43
  id, role: 'assistant', content: chunk,
44
44
  isStreaming: true, timestamp: new Date(),
45
45
  _chunks: [chunk],
46
- });
46
+ };
47
+ messages.value.push(newMsg);
47
48
  streamingMessageId = id;
48
49
  } else {
49
50
  streamMsg._chunks.push(chunk);
@@ -64,11 +65,12 @@ export function createStreaming({ messages, scrollToBottom }) {
64
65
  streamMsg.content = streamMsg._chunks.join('');
65
66
  } else {
66
67
  const id = ++messageIdCounter;
67
- messages.value.push({
68
+ const newMsg = {
68
69
  id, role: 'assistant', content: pendingText,
69
70
  isStreaming: true, timestamp: new Date(),
70
71
  _chunks: [pendingText],
71
- });
72
+ };
73
+ messages.value.push(newMsg);
72
74
  streamingMessageId = id;
73
75
  }
74
76
  pendingText = '';