kiro-mobile-bridge 1.0.13 → 1.0.15

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A lightweight mobile interface that lets you monitor and control Kiro IDE agent sessions from your phone over LAN, with a live preview of chat, tasks, and code via Chrome DevTools Protocol.
4
4
 
5
- <img width="1829" height="1065" alt="Untitled design (2)" src="https://github.com/user-attachments/assets/6f55e527-7e66-46b6-b0fe-c2a5a527dec4" />
5
+ <img width="1829" height="1065" alt="Untitled design (4)" src="https://github.com/user-attachments/assets/d548c43b-4501-4d66-aed7-ad021a44f9cb" />
6
6
 
7
7
 
8
8
  ## Features
@@ -24,14 +24,9 @@ A lightweight mobile interface that lets you monitor and control Kiro IDE agent
24
24
 
25
25
  Start Kiro with the remote debugging port enabled:
26
26
 
27
- **Run Kiro with debugging port:**
27
+ **Run Kiro with debugging port on terminal:**
28
28
  ```bash
29
- # Windows (from default install location)
30
- "%LOCALAPPDATA%\Programs\Kiro\Kiro.exe" --remote-debugging-port=9000
31
- # macOS
32
- /Applications/Kiro.app/Contents/MacOS/Kiro --remote-debugging-port=9000
33
- # Linux (installed)
34
- /opt/Kiro/kiro --remote-debugging-port=9000
29
+ kiro --remote-debugging-port=9000
35
30
  ```
36
31
 
37
32
  ### 2. Run with npx (Recommended)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiro-mobile-bridge",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "A simple mobile web interface for monitoring Kiro IDE agent sessions from your phone over LAN",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -214,9 +214,10 @@
214
214
  .empty-state p { font-size: 14px; max-width: 280px; }
215
215
 
216
216
  /* =============================================================================
217
- Mobile Bridge Customizations - Hide Follow and Revert buttons in snackbar
217
+ Mobile Bridge Customizations - Hide Follow button in snackbar (keep "Looks good to me")
218
218
  ============================================================================= */
219
- .kiro-snackbar-actions .kiro-button[data-variant="primary"] { display: none !important; }
219
+ /* Hide only the Follow button by targeting buttons containing "Follow" text - done via JS */
220
+ /* Note: "Looks good to me" and other action buttons should remain visible */
220
221
  </style>
221
222
  </head>
222
223
  <body>
@@ -1083,10 +1084,11 @@
1083
1084
  }
1084
1085
 
1085
1086
  function hideRevertButton() {
1086
- // Hide Revert button by finding buttons with "Revert" text in snackbar
1087
+ // Hide Revert and Follow buttons by finding buttons with specific text in snackbar
1088
+ // Note: "Looks good to me" and other action buttons should remain visible
1087
1089
  panels.chat.content.querySelectorAll('.kiro-snackbar button, .kiro-snackbar-actions button').forEach(btn => {
1088
1090
  const text = (btn.textContent || '').trim().toLowerCase();
1089
- if (text === 'revert') {
1091
+ if (text === 'revert' || text === 'follow') {
1090
1092
  btn.style.display = 'none';
1091
1093
  }
1092
1094
  });
@@ -1147,15 +1149,19 @@
1147
1149
  const lines = data.content.split('\n');
1148
1150
  let html = '';
1149
1151
 
1152
+ // Show info banner for partial content
1150
1153
  if (data.isPartial || data.note) {
1151
- html += `<div style="padding: 8px 12px; background: #2d2d30; color: #888; font-size: 11px; border-bottom: 1px solid #3c3c3c;">
1152
- <span class="codicon codicon-info" style="margin-right: 6px;"></span>${data.note || 'Showing visible lines only.'}
1154
+ const noteText = data.note || 'Showing visible lines only. Use Explorer to open full file.';
1155
+ html += `<div style="padding: 10px 12px; background: linear-gradient(135deg, #2d2d30 0%, #252526 100%); color: #a78bfa; font-size: 12px; border-bottom: 1px solid #3c3c3c; display: flex; align-items: center; gap: 8px;">
1156
+ <span class="codicon codicon-warning" style="color: #f0b429;"></span>
1157
+ <span>${noteText}</span>
1153
1158
  </div>`;
1154
1159
  }
1155
1160
 
1156
1161
  html += '<pre class="editor-code">';
1157
1162
  const startLineNum = data.startLine || 1;
1158
1163
  let startIdx = 0;
1164
+ // Skip leading empty lines (max 3)
1159
1165
  while (startIdx < lines.length && lines[startIdx].trim() === '' && startIdx < 3) startIdx++;
1160
1166
 
1161
1167
  lines.slice(startIdx).forEach((line, idx) => {
@@ -1165,9 +1171,17 @@
1165
1171
  });
1166
1172
  html += '</pre>';
1167
1173
 
1174
+ // Footer with line info
1168
1175
  const endLine = startLineNum + startIdx + lines.slice(startIdx).length - 1;
1176
+ const lineInfo = data.lineCount
1177
+ ? `Lines ${startLineNum}-${endLine} of ${data.lineCount}`
1178
+ : `Lines ${startLineNum}-${endLine}`;
1179
+ const fullFileIndicator = !data.isPartial && data.lineCount && endLine >= data.lineCount
1180
+ ? ' <span style="color: #4caf50;">✓ Complete file</span>'
1181
+ : '';
1182
+
1169
1183
  html += `<div style="padding: 6px 12px; background: #252526; color: #666; font-size: 11px; border-top: 1px solid #3c3c3c;">
1170
- Lines ${startLineNum}-${endLine}${data.lineCount ? ` of ${data.lineCount}` : ''}
1184
+ ${lineInfo}${fullFileIndicator}
1171
1185
  </div>`;
1172
1186
 
1173
1187
  panels.editor.content.innerHTML = html;
@@ -2032,7 +2046,21 @@
2032
2046
  async function openFileInEditor(filePath) {
2033
2047
  if (!selectedCascadeId || !filePath) return;
2034
2048
 
2035
- showToast(`Opening ${filePath}...`, 1500);
2049
+ // Clean the file path - remove extra whitespace, quotes, backticks, and common prefixes
2050
+ let cleanPath = filePath.trim()
2051
+ .replace(/^['"`]+|['"`]+$/g, '') // Remove surrounding quotes
2052
+ .replace(/^\s*#\s*/, '') // Remove markdown header prefix
2053
+ .replace(/^\s*[-*]\s*/, '') // Remove list markers
2054
+ .replace(/\s+/g, ' ') // Normalize whitespace
2055
+ .trim();
2056
+
2057
+ // Extract just the file path if it contains extra text (e.g., "file.ts - some description")
2058
+ const pathMatch = cleanPath.match(/^([^\s]+\.(ts|tsx|js|jsx|py|java|html|css|json|md|yaml|yml|xml|sql|go|rs|c|cpp|h|cs|rb|php|sh|vue|svelte))/i);
2059
+ if (pathMatch) {
2060
+ cleanPath = pathMatch[1];
2061
+ }
2062
+
2063
+ showToast(`Opening ${cleanPath}...`, 1500);
2036
2064
 
2037
2065
  // Switch to Editor tab
2038
2066
  const editorTab = document.querySelector('[data-panel="editor"]');
@@ -2050,25 +2078,30 @@
2050
2078
  const readResult = await fetch(`/readFile/${selectedCascadeId}`, {
2051
2079
  method: 'POST',
2052
2080
  headers: { 'Content-Type': 'application/json' },
2053
- body: JSON.stringify({ filePath })
2081
+ body: JSON.stringify({ filePath: cleanPath })
2054
2082
  });
2055
2083
 
2056
2084
  if (readResult.ok) {
2057
2085
  const data = await readResult.json();
2058
2086
  if (data.content) {
2059
2087
  renderEditorSnapshot(data);
2060
- showToast(`Opened ${data.fileName || filePath}`, 1500, true);
2088
+ showToast(`Opened ${data.fileName || cleanPath}`, 1500, true);
2061
2089
  return;
2062
2090
  }
2091
+ } else {
2092
+ // Log the error for debugging
2093
+ const errorData = await readResult.json().catch(() => ({}));
2094
+ console.log('[OpenFile] Direct read failed:', errorData.error || readResult.status);
2063
2095
  }
2064
2096
 
2065
- // Fallback: Click file link in Kiro
2097
+ // Fallback: Click file link in Kiro to open it there
2066
2098
  await fetch(`/click/${selectedCascadeId}`, {
2067
2099
  method: 'POST',
2068
2100
  headers: { 'Content-Type': 'application/json' },
2069
- body: JSON.stringify({ tag: 'a', text: filePath, isFileLink: true, filePath })
2101
+ body: JSON.stringify({ tag: 'a', text: cleanPath, isFileLink: true, filePath: cleanPath })
2070
2102
  });
2071
2103
 
2104
+ // Wait for Kiro to open the file
2072
2105
  await new Promise(resolve => setTimeout(resolve, 800));
2073
2106
 
2074
2107
  const fetchEditor = async () => {
@@ -2076,7 +2109,11 @@
2076
2109
  const r = await fetch(`/editor/${selectedCascadeId}`);
2077
2110
  if (r.ok) {
2078
2111
  const data = await r.json();
2079
- if (data.content || data.html) { renderEditorSnapshot(data); showToast(`Opened ${data.fileName || filePath}`, 1500, true); return true; }
2112
+ if (data.content || data.html) {
2113
+ renderEditorSnapshot(data);
2114
+ showToast(`Opened ${data.fileName || cleanPath}`, 1500, true);
2115
+ return true;
2116
+ }
2080
2117
  }
2081
2118
  } catch (e) {}
2082
2119
  return false;
@@ -2085,10 +2122,11 @@
2085
2122
  if (!await fetchEditor()) {
2086
2123
  await new Promise(resolve => setTimeout(resolve, 500));
2087
2124
  if (!await fetchEditor()) {
2088
- showEmptyState('editor', 'codicon-file-code', `Could not load ${filePath}.`);
2125
+ showEmptyState('editor', 'codicon-file-code', `Could not load ${cleanPath}. Try opening it in Kiro first.`);
2089
2126
  }
2090
2127
  }
2091
2128
  } catch (e) {
2129
+ console.error('[OpenFile] Error:', e);
2092
2130
  showToast('Failed to open file');
2093
2131
  showEmptyState('editor', 'codicon-file-code', 'Failed to load file.');
2094
2132
  }
package/src/routes/api.js CHANGED
@@ -215,6 +215,7 @@ export function createApiRouter(cascades, mainWindowCDP) {
215
215
  try {
216
216
  // Get workspace root
217
217
  const workspaceRoot = await getWorkspaceRoot(mainWindowCDP) || process.cwd();
218
+ console.log(`[ReadFile] Workspace root: ${workspaceRoot}`);
218
219
 
219
220
  // SECURITY: Validate path is within workspace root
220
221
  const pathValidation = validatePathWithinRoot(sanitizedPath, workspaceRoot);
@@ -222,19 +223,46 @@ export function createApiRouter(cascades, mainWindowCDP) {
222
223
  let content = null;
223
224
  let foundPath = null;
224
225
 
226
+ // Try 1: Direct path validation
225
227
  if (pathValidation.valid) {
226
- // Try the validated path first
227
228
  try {
228
229
  content = await fs.readFile(pathValidation.resolvedPath, 'utf-8');
229
230
  foundPath = pathValidation.resolvedPath;
231
+ console.log(`[ReadFile] Found at validated path: ${foundPath}`);
230
232
  } catch (e) {
231
- // File doesn't exist at validated path, try searching
233
+ // File doesn't exist at validated path, try other methods
232
234
  }
233
235
  }
234
236
 
235
- // If not found, search within workspace only
237
+ // Try 2: Common path variations
238
+ if (!content) {
239
+ const pathVariations = [
240
+ sanitizedPath,
241
+ sanitizedPath.replace(/^\.\//, ''), // Remove leading ./
242
+ sanitizedPath.replace(/^\//, ''), // Remove leading /
243
+ `src/${sanitizedPath}`, // Try in src/
244
+ `src/${sanitizedPath.replace(/^src\//, '')}`,
245
+ ];
246
+
247
+ for (const variation of pathVariations) {
248
+ const varValidation = validatePathWithinRoot(variation, workspaceRoot);
249
+ if (varValidation.valid) {
250
+ try {
251
+ content = await fs.readFile(varValidation.resolvedPath, 'utf-8');
252
+ foundPath = varValidation.resolvedPath;
253
+ console.log(`[ReadFile] Found at variation: ${foundPath}`);
254
+ break;
255
+ } catch (e) {
256
+ // Continue to next variation
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ // Try 3: Search by filename within workspace
236
263
  if (!content) {
237
264
  const fileName = path.basename(sanitizedPath);
265
+ console.log(`[ReadFile] Searching for filename: ${fileName}`);
238
266
  foundPath = await findFileRecursive(workspaceRoot, fileName, MAX_FILE_SEARCH_DEPTH);
239
267
 
240
268
  if (foundPath) {
@@ -242,6 +270,7 @@ export function createApiRouter(cascades, mainWindowCDP) {
242
270
  const foundValidation = validatePathWithinRoot(foundPath, workspaceRoot);
243
271
  if (foundValidation.valid) {
244
272
  content = await fs.readFile(foundPath, 'utf-8');
273
+ console.log(`[ReadFile] Found via search: ${foundPath}`);
245
274
  } else {
246
275
  console.warn(`[ReadFile] Found file outside workspace: ${foundPath}`);
247
276
  foundPath = null;
@@ -249,7 +278,28 @@ export function createApiRouter(cascades, mainWindowCDP) {
249
278
  }
250
279
  }
251
280
 
281
+ // Try 4: Search with partial path matching
282
+ if (!content && sanitizedPath.includes('/')) {
283
+ const pathParts = sanitizedPath.split('/');
284
+ const fileName = pathParts[pathParts.length - 1];
285
+ const parentDir = pathParts[pathParts.length - 2];
286
+
287
+ if (parentDir && fileName) {
288
+ console.log(`[ReadFile] Searching for ${parentDir}/${fileName}`);
289
+ foundPath = await findFileWithParent(workspaceRoot, parentDir, fileName, MAX_FILE_SEARCH_DEPTH);
290
+
291
+ if (foundPath) {
292
+ const foundValidation = validatePathWithinRoot(foundPath, workspaceRoot);
293
+ if (foundValidation.valid) {
294
+ content = await fs.readFile(foundPath, 'utf-8');
295
+ console.log(`[ReadFile] Found via parent search: ${foundPath}`);
296
+ }
297
+ }
298
+ }
299
+ }
300
+
252
301
  if (!content) {
302
+ console.log(`[ReadFile] File not found: ${sanitizedPath}`);
253
303
  return res.status(404).json({ error: 'File not found within workspace' });
254
304
  }
255
305
 
@@ -257,7 +307,7 @@ export function createApiRouter(cascades, mainWindowCDP) {
257
307
 
258
308
  res.json({
259
309
  content,
260
- fileName: path.basename(sanitizedPath),
310
+ fileName: path.basename(foundPath || sanitizedPath),
261
311
  fullPath: foundPath,
262
312
  language,
263
313
  lineCount: content.split('\n').length,
@@ -489,6 +539,56 @@ async function findFileRecursive(dir, fileName, maxDepth = MAX_FILE_SEARCH_DEPTH
489
539
  return null;
490
540
  }
491
541
 
542
+ /**
543
+ * Find file with matching parent directory name
544
+ * Useful when path is like "services/snapshot.js" and we need to find the right one
545
+ * @param {string} dir - Directory to search
546
+ * @param {string} parentDirName - Expected parent directory name
547
+ * @param {string} fileName - File name to find
548
+ * @param {number} maxDepth - Maximum search depth
549
+ * @param {number} currentDepth - Current depth (internal)
550
+ * @returns {Promise<string|null>}
551
+ */
552
+ async function findFileWithParent(dir, parentDirName, fileName, maxDepth = MAX_FILE_SEARCH_DEPTH, currentDepth = 0) {
553
+ if (currentDepth > maxDepth) return null;
554
+
555
+ try {
556
+ const entries = await fs.readdir(dir, { withFileTypes: true });
557
+
558
+ // Check if current directory matches parent name and contains the file
559
+ const currentDirName = path.basename(dir);
560
+ if (currentDirName === parentDirName) {
561
+ for (const entry of entries) {
562
+ if (entry.isFile() && entry.name === fileName) {
563
+ return path.join(dir, entry.name);
564
+ }
565
+ }
566
+ }
567
+
568
+ // Recurse into directories
569
+ for (const entry of entries) {
570
+ if (entry.isDirectory() &&
571
+ (!entry.name.startsWith('.') || entry.name === '.kiro') &&
572
+ entry.name !== 'node_modules' &&
573
+ entry.name !== 'dist' &&
574
+ entry.name !== 'build' &&
575
+ entry.name !== '.git') {
576
+ const found = await findFileWithParent(
577
+ path.join(dir, entry.name),
578
+ parentDirName,
579
+ fileName,
580
+ maxDepth,
581
+ currentDepth + 1
582
+ );
583
+ if (found) return found;
584
+ }
585
+ }
586
+ } catch (e) {
587
+ // Directory not accessible, skip
588
+ }
589
+ return null;
590
+ }
591
+
492
592
  /**
493
593
  * Collect workspace files for file tree
494
594
  * @param {string} workspaceRoot - Workspace root directory
@@ -7,13 +7,27 @@ import { CDP_CALL_TIMEOUT, HTTP_TIMEOUT } from '../utils/constants.js';
7
7
 
8
8
  /**
9
9
  * Fetch JSON from a CDP endpoint
10
+ * Tries both 127.0.0.1 and localhost for cross-platform compatibility
10
11
  * @param {number} port - The port to fetch from
11
12
  * @param {string} path - The path to fetch (default: /json/list)
12
13
  * @returns {Promise<any>} - Parsed JSON response
13
14
  */
14
15
  export function fetchCDPTargets(port, path = '/json/list') {
16
+ // Try 127.0.0.1 first, then localhost as fallback
17
+ return fetchFromHost('127.0.0.1', port, path)
18
+ .catch(() => fetchFromHost('localhost', port, path));
19
+ }
20
+
21
+ /**
22
+ * Fetch JSON from a specific host
23
+ * @param {string} host - The host to connect to
24
+ * @param {number} port - The port to fetch from
25
+ * @param {string} path - The path to fetch
26
+ * @returns {Promise<any>} - Parsed JSON response
27
+ */
28
+ function fetchFromHost(host, port, path) {
15
29
  return new Promise((resolve, reject) => {
16
- const url = `http://127.0.0.1:${port}${path}`;
30
+ const url = `http://${host}:${port}${path}`;
17
31
 
18
32
  const req = http.get(url, { timeout: HTTP_TIMEOUT }, (res) => {
19
33
  let data = '';
@@ -285,28 +285,96 @@ export async function captureEditor(cdp) {
285
285
  }
286
286
  }
287
287
 
288
- // Try Monaco API
288
+ // Method 1: Try VS Code/Kiro API (acquireVsCodeApi or global vscode)
289
289
  try {
290
- const monacoEditors = targetDoc.querySelectorAll('.monaco-editor');
291
- for (const editorEl of monacoEditors) {
292
- const editorInstance = editorEl.__vscode_editor__ || editorEl._editor ||
293
- (window.monaco && window.monaco.editor && window.monaco.editor.getEditors && window.monaco.editor.getEditors()[0]);
294
- if (editorInstance && editorInstance.getModel) {
295
- const model = editorInstance.getModel();
296
- if (model) {
290
+ // Check for VS Code webview API
291
+ if (typeof acquireVsCodeApi === 'function') {
292
+ const vscode = acquireVsCodeApi();
293
+ if (vscode && vscode.getState) {
294
+ const state = vscode.getState();
295
+ if (state && state.content) {
296
+ result.content = state.content;
297
+ result.lineCount = state.content.split('\\n').length;
298
+ result.hasContent = true;
299
+ }
300
+ }
301
+ }
302
+ } catch(e) {
303
+ // VS Code API not available
304
+ }
305
+
306
+ // Method 2: Try Monaco API with multiple access patterns
307
+ if (!result.content) {
308
+ try {
309
+ const monacoEditors = targetDoc.querySelectorAll('.monaco-editor');
310
+ for (const editorEl of monacoEditors) {
311
+ // Try various ways to access the editor instance
312
+ let editorInstance = null;
313
+
314
+ // Direct property access
315
+ editorInstance = editorEl.__vscode_editor__ || editorEl._editor;
316
+
317
+ // Try global monaco
318
+ if (!editorInstance && window.monaco && window.monaco.editor) {
319
+ const editors = window.monaco.editor.getEditors ? window.monaco.editor.getEditors() : [];
320
+ if (editors.length > 0) editorInstance = editors[0];
321
+ }
322
+
323
+ // Try to find editor via data attributes or parent elements
324
+ if (!editorInstance) {
325
+ const editorContainer = editorEl.closest('[data-uri]') || editorEl.closest('.editor-instance');
326
+ if (editorContainer && editorContainer._editor) {
327
+ editorInstance = editorContainer._editor;
328
+ }
329
+ }
330
+
331
+ if (editorInstance && editorInstance.getModel) {
332
+ const model = editorInstance.getModel();
333
+ if (model) {
334
+ result.content = model.getValue();
335
+ result.lineCount = model.getLineCount();
336
+ result.language = model.getLanguageId ? model.getLanguageId() : '';
337
+ result.hasContent = true;
338
+ break;
339
+ }
340
+ }
341
+ }
342
+ } catch(e) {
343
+ // Monaco API not available, continue
344
+ }
345
+ }
346
+
347
+ // Method 3: Try to get content from Monaco's internal model store
348
+ if (!result.content) {
349
+ try {
350
+ if (window.monaco && window.monaco.editor) {
351
+ const models = window.monaco.editor.getModels ? window.monaco.editor.getModels() : [];
352
+ // Find the model that matches the active file
353
+ for (const model of models) {
354
+ const uri = model.uri ? model.uri.toString() : '';
355
+ if (result.fileName && uri.includes(result.fileName)) {
356
+ result.content = model.getValue();
357
+ result.lineCount = model.getLineCount();
358
+ result.language = model.getLanguageId ? model.getLanguageId() : '';
359
+ result.hasContent = true;
360
+ break;
361
+ }
362
+ }
363
+ // If no match by filename, use the first model with content
364
+ if (!result.content && models.length > 0) {
365
+ const model = models[0];
297
366
  result.content = model.getValue();
298
367
  result.lineCount = model.getLineCount();
299
368
  result.language = model.getLanguageId ? model.getLanguageId() : '';
300
369
  result.hasContent = true;
301
- break;
302
370
  }
303
371
  }
372
+ } catch(e) {
373
+ // Model store not accessible
304
374
  }
305
- } catch(e) {
306
- // Monaco API not available, continue
307
375
  }
308
376
 
309
- // Fallback: Extract from view-lines
377
+ // Method 4: Fallback - Extract from view-lines (visible lines only)
310
378
  if (!result.content) {
311
379
  const viewLines = targetDoc.querySelector('.monaco-editor .view-lines');
312
380
  if (viewLines) {
@@ -316,25 +384,62 @@ export async function captureEditor(cdp) {
316
384
  let minLineNum = Infinity, maxLineNum = 0;
317
385
  const lineMap = new Map();
318
386
 
387
+ // Get line height from first line for accurate line number calculation
388
+ let lineHeight = 19;
389
+ if (lines[0]) {
390
+ const firstLineTop = parseFloat(lines[0].style.top) || 0;
391
+ const secondLine = lines[1];
392
+ if (secondLine) {
393
+ const secondLineTop = parseFloat(secondLine.style.top) || 0;
394
+ if (secondLineTop > firstLineTop) {
395
+ lineHeight = secondLineTop - firstLineTop;
396
+ }
397
+ }
398
+ }
399
+
319
400
  lines.forEach(line => {
320
401
  const top = parseFloat(line.style.top) || 0;
321
- const lineNum = Math.round(top / 19) + 1;
402
+ const lineNum = Math.round(top / lineHeight) + 1;
322
403
  lineMap.set(lineNum, line.textContent || '');
323
404
  minLineNum = Math.min(minLineNum, lineNum);
324
405
  maxLineNum = Math.max(maxLineNum, lineNum);
325
406
  });
326
407
 
327
- for (let i = minLineNum; i <= Math.min(maxLineNum, minLineNum + 500); i++) {
408
+ // Get total line count from scrollbar or line numbers if available
409
+ let totalLines = maxLineNum;
410
+ const lineNumbersContainer = targetDoc.querySelector('.monaco-editor .line-numbers');
411
+ if (lineNumbersContainer) {
412
+ const lastLineNum = lineNumbersContainer.querySelector('.line-numbers:last-child');
413
+ if (lastLineNum) {
414
+ const num = parseInt(lastLineNum.textContent, 10);
415
+ if (!isNaN(num) && num > totalLines) totalLines = num;
416
+ }
417
+ }
418
+
419
+ // Also try to get total from editor's scrollable height
420
+ const scrollableElement = targetDoc.querySelector('.monaco-editor .monaco-scrollable-element');
421
+ if (scrollableElement) {
422
+ const scrollHeight = scrollableElement.scrollHeight;
423
+ const estimatedLines = Math.ceil(scrollHeight / lineHeight);
424
+ if (estimatedLines > totalLines) totalLines = estimatedLines;
425
+ }
426
+
427
+ for (let i = minLineNum; i <= maxLineNum; i++) {
328
428
  codeContent += (lineMap.get(i) || '') + '\\n';
329
429
  }
330
430
 
331
431
  result.content = codeContent;
332
- result.lineCount = maxLineNum;
432
+ result.lineCount = totalLines;
333
433
  result.startLine = minLineNum;
434
+ result.endLine = maxLineNum;
334
435
  result.hasContent = codeContent.trim().length > 0;
335
- if (minLineNum > 1) {
336
- result.isPartial = true;
337
- result.note = 'Showing lines ' + minLineNum + '-' + maxLineNum + '. Scroll in Kiro to see other parts.';
436
+
437
+ // Always mark as partial when using view-lines fallback
438
+ result.isPartial = true;
439
+ if (minLineNum > 1 || maxLineNum < totalLines) {
440
+ result.note = 'Showing lines ' + minLineNum + '-' + maxLineNum + ' of ' + totalLines + '. Use Explorer to open full file.';
441
+ } else {
442
+ result.note = 'Visible lines only. Use Explorer to open full file.';
338
443
  }
339
444
  }
340
445
  }
@@ -1,11 +1,19 @@
1
1
  /**
2
- * Network utilities
2
+ * Network utilities for cross-platform local IP detection
3
+ *
4
+ * Supports: Windows, macOS, Linux
5
+ *
6
+ * Node.js os.networkInterfaces() family property:
7
+ * - Node < 18.0.0: returns string ('IPv4' or 'IPv6')
8
+ * - Node 18.0.0 - 18.3.x: returns number (4 or 6)
9
+ * - Node >= 18.4.0: returns string ('IPv4' or 'IPv6')
3
10
  */
4
11
  import { networkInterfaces } from 'os';
5
12
 
6
13
  /**
7
14
  * Check if interface is IPv4
8
15
  * Handles both string ('IPv4') and number (4) family values
16
+ * for compatibility across Node.js versions
9
17
  * @param {object} iface - Network interface object
10
18
  * @returns {boolean}
11
19
  */
@@ -13,22 +21,66 @@ function isIPv4(iface) {
13
21
  return iface.family === 'IPv4' || iface.family === 4;
14
22
  }
15
23
 
24
+ /**
25
+ * Check if interface name is a virtual/container interface to skip
26
+ * @param {string} name - Interface name
27
+ * @returns {boolean}
28
+ */
29
+ function isVirtualInterface(name) {
30
+ const lowerName = name.toLowerCase();
31
+ const virtualPatterns = [
32
+ 'vethernet', // Windows WSL/Hyper-V
33
+ 'docker', // Docker
34
+ 'vmware', // VMware
35
+ 'virtualbox', // VirtualBox
36
+ 'vbox', // VirtualBox alternate
37
+ 'virbr', // Linux libvirt bridge
38
+ 'br-', // Docker bridge
39
+ 'veth', // Virtual ethernet
40
+ 'tailscale', // Tailscale VPN
41
+ 'tun', // VPN tunnel
42
+ 'tap', // VPN tap
43
+ 'utun', // macOS VPN
44
+ 'awdl', // Apple Wireless Direct Link
45
+ 'llw', // Apple Low Latency WLAN
46
+ 'bridge', // Bridge interfaces
47
+ 'ham', // Hamachi VPN
48
+ 'zt', // ZeroTier
49
+ ];
50
+
51
+ return virtualPatterns.some(pattern => lowerName.includes(pattern));
52
+ }
53
+
16
54
  /**
17
55
  * Get local IP address for LAN access
18
- * Returns the first non-internal IPv4 address found.
56
+ * Returns the first non-internal IPv4 address found, prioritizing
57
+ * physical network interfaces over virtual ones.
19
58
  *
20
- * NOTE: On systems with multiple network interfaces, this returns the first one found.
21
- * For more control, consider using environment variables or configuration.
59
+ * Platform-specific interface names:
60
+ * - Windows: 'Ethernet', 'Wi-Fi', 'Ethernet 2', 'Local Area Connection'
61
+ * - macOS: 'en0' (Wi-Fi), 'en1' (Ethernet), 'en2', etc.
62
+ * - Linux: 'eth0', 'enp0s3', 'ens33', 'wlan0', 'wlp2s0'
22
63
  *
23
64
  * @returns {string} - Local IP or 'localhost' if no suitable interface found
24
65
  */
25
66
  export function getLocalIP() {
26
67
  const interfaces = networkInterfaces();
27
68
 
28
- // Prioritize common interface names (Linux, macOS, Windows)
29
- const priorityInterfaces = ['Ethernet', 'Wi-Fi', 'eth0', 'en0', 'wlan0'];
69
+ // Priority 1: Common physical interface names across platforms
70
+ // Order matters - check most common first
71
+ const priorityInterfaces = [
72
+ // Windows
73
+ 'Ethernet', 'Wi-Fi', 'Ethernet 2', 'Local Area Connection',
74
+ // macOS
75
+ 'en0', 'en1', 'en2', 'en3', 'en4', 'en5',
76
+ // Linux (traditional)
77
+ 'eth0', 'eth1', 'wlan0', 'wlan1',
78
+ // Linux (systemd predictable names)
79
+ 'enp0s3', 'enp0s25', 'enp0s31f6', 'ens33', 'ens160', 'ens192',
80
+ 'wlp2s0', 'wlp3s0', 'wlp0s20f3',
81
+ ];
30
82
 
31
- // First, try priority interfaces
83
+ // First pass: try priority interfaces
32
84
  for (const name of priorityInterfaces) {
33
85
  const ifaces = interfaces[name];
34
86
  if (ifaces) {
@@ -40,15 +92,10 @@ export function getLocalIP() {
40
92
  }
41
93
  }
42
94
 
43
- // Fallback: return first non-internal IPv4 (skip virtual/WSL interfaces)
95
+ // Second pass: any non-virtual interface
44
96
  for (const name of Object.keys(interfaces)) {
45
- // Skip virtual interfaces (WSL, Docker, VPN, etc.)
46
- if (name.toLowerCase().includes('vethernet') ||
47
- name.toLowerCase().includes('docker') ||
48
- name.toLowerCase().includes('vmware') ||
49
- name.toLowerCase().includes('virtualbox')) {
50
- continue;
51
- }
97
+ if (isVirtualInterface(name)) continue;
98
+
52
99
  for (const iface of interfaces[name]) {
53
100
  if (isIPv4(iface) && !iface.internal) {
54
101
  return iface.address;
@@ -56,7 +103,7 @@ export function getLocalIP() {
56
103
  }
57
104
  }
58
105
 
59
- // Last resort: any non-internal IPv4
106
+ // Last resort: any non-internal IPv4 (including virtual)
60
107
  for (const name of Object.keys(interfaces)) {
61
108
  for (const iface of interfaces[name]) {
62
109
  if (isIPv4(iface) && !iface.internal) {