@hklmtt/falinks
Advanced tools
+8
-0
| # Changelog | ||
| ## 0.12.1 | ||
| - **修复:控制台消息残影(同一行重复刷屏几十遍)**:自绘渲染的列宽函数把 `✅ ❌ ⏳` 等东亚 Wide 符号按 1 格算(实际 2 格)→ 行超宽物理折行 → 整帧顶滚 → 每渲染一轮在视口顶部累积一份残影(todolist 汇总消息含 ✅❌,集中触发)。修复:宽度表补齐 Unicode EAW=W 符号区段 + 全部 UI 行 `truncate-end` + 行宽收到 cols-1,带回归测试。 | ||
| - **模型选择更友好**:`/add` 向导的模型步改为**预设选择器**——claude 员工七项(默认 / `opus[1m]` / `opus` / `sonnet[1m]` / `sonnet` / `haiku` / 自定义…),codex 员工两项(默认 / 自定义);预设用 Claude Code 别名(跨版本稳定),`[1m]` = 1M 上下文变体(已实测端到端生效);选「自定义…」回到文本输入兜底。 | ||
| - **修复:⚠ 失联警告文案误导**:按触发规则分流——"有活无声"(如恢复的会话上下文耗尽瘫痪)显示「收到消息却全程零工具调用——会话可能瘫痪,试试 /restart <名字> fresh」,不再对本就无需报到的恢复员工说「未报到」。 | ||
| - **修复:粘贴文件路径被误判成命令**:以路径开头的文本输入(iTerm 原生粘贴/拖文件,首词含第二个 `/`)按普通回复处理,不再吃「未知命令」;真命令与命令参数里的路径不受影响。 | ||
| - 文档:向导提示与 README 补 `[1m]` 后缀语法说明。 | ||
| ## 0.12.0 | ||
@@ -4,0 +12,0 @@ |
@@ -151,3 +151,3 @@ import http from 'node:http'; | ||
| if (req.method === 'GET' && url.pathname === '/admin/roster') { | ||
| return sendJson({ roster: router.roster().map((a) => ({ name: a.name, role: a.role, status: a.status, virtual: !!a.virtual, lead: !!a.lead, unresponsive: !!a.unresponsive, mcpSeen: a.lastMcpHttpAt != null })) }); | ||
| return sendJson({ roster: router.roster().map((a) => ({ name: a.name, role: a.role, status: a.status, virtual: !!a.virtual, lead: !!a.lead, unresponsive: !!a.unresponsive, rule: a.unresponsiveRule, mcpSeen: a.lastMcpHttpAt != null })) }); | ||
| } | ||
@@ -154,0 +154,0 @@ if (req.method === 'GET' && url.pathname === '/admin/log') { |
+44
-11
@@ -8,7 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; | ||
| import { commandState, applyCommand, todoSubState } from './commands.js'; | ||
| import { CLIS, dirSuggestions, fsListDirs } from './wizard.js'; | ||
| import { CLIS, dirSuggestions, fsListDirs, MODEL_PRESETS } from './wizard.js'; | ||
| import { nameColor, formatTime, NAME_COLORS, statusGlyph, SPINNER_FRAMES } from './log-format.js'; | ||
| import { renderMarkdown } from './markdown.js'; | ||
| import { decodeKey, wheelBurst } from './keys.js'; | ||
| import { appendCommitted, pendingCounts, wrapSegs, sliceView, clampOffset } from './scrollback.js'; | ||
| import { appendCommitted, pendingCounts, wrapSegs, sliceView, clampOffset, clipDisp, dispWidth } from './scrollback.js'; | ||
| import { saveClipboardImage, expandImageTokens } from './clipboard.js'; | ||
@@ -78,3 +78,4 @@ import { t, setLocale } from '../i18n/index.js'; | ||
| const { stdout } = useStdout(); | ||
| // 终端尺寸跟踪:alt screen 全屏自绘,根盒钉 rows-1 × cols(严格小于终端行数,防最后一行换行顶滚)。 | ||
| // 终端尺寸跟踪:alt screen 全屏自绘,根盒钉 rows-1 × cols-1(严格小于终端行/列数: | ||
| // 行少 1 防末行换行顶滚;列少 1 防恰满宽行触发终端行尾 pending-wrap/物理折行)。 | ||
| // 用 || 不用 ??:无 tty/pty 异常时 rows/columns 可能是 0,0 也要落到默认值;再加下限防极端小窗。 | ||
@@ -422,3 +423,3 @@ const readDims = () => ({ rows: Math.max(6, stdout?.rows || 24), cols: Math.max(20, stdout?.columns || 80) }); | ||
| if (ev.type === 'enter' || ev.type === 'tab') { | ||
| setWizard({ name: wizard.name, step: 'model', cli: CLIS[wizard.sel], modelText: '' }); | ||
| setWizard({ name: wizard.name, step: 'model', cli: CLIS[wizard.sel], sel: 0 }); | ||
| return; | ||
@@ -429,2 +430,24 @@ } | ||
| if (wizard.step === 'model') { | ||
| // 模型预设选择:↑↓ 在预设里移动,enter/tab 确认;选中"自定义…"哨兵转文本子步 | ||
| const presets = MODEL_PRESETS(wizard.cli); | ||
| if (ev.type === 'up') { | ||
| setWizard({ ...wizard, sel: Math.max(0, wizard.sel - 1) }); | ||
| return; | ||
| } | ||
| if (ev.type === 'down') { | ||
| setWizard({ ...wizard, sel: Math.min(presets.length - 1, wizard.sel + 1) }); | ||
| return; | ||
| } | ||
| if (ev.type === 'enter' || ev.type === 'tab') { | ||
| const picked = presets[wizard.sel]; | ||
| if (picked.value === 'custom') { | ||
| setWizard({ name: wizard.name, step: 'model-custom', cli: wizard.cli, modelText: '' }); | ||
| return; | ||
| } | ||
| setWizard({ name: wizard.name, step: 'role', cli: wizard.cli, model: picked.value, roleText: '' }); | ||
| return; | ||
| } | ||
| return; | ||
| } | ||
| if (wizard.step === 'model-custom') { | ||
| if (ev.type === 'enter') { | ||
@@ -817,17 +840,27 @@ setWizard({ name: wizard.name, step: 'role', cli: wizard.cli, model: wizard.modelText.trim() || undefined, roleText: '' }); | ||
| // 失联员工警告(roster 驱动:员工一恢复 MCP 调用标志即清,警告行自动消失)。 | ||
| const unresp = roster.filter((a) => a.unresponsive).map((a) => ({ name: a.name, mcpSeen: !!a.mcpSeen })); | ||
| const unresp = roster.filter((a) => a.unresponsive).map((a) => ({ name: a.name, mcpSeen: !!a.mcpSeen, rule: a.rule })); | ||
| return ( | ||
| // alt screen 全屏自绘(CC 同款):根盒钉 rows-1 行(严格小于终端行数,防末行换行顶滚)。 | ||
| // alt screen 全屏自绘(CC 同款):根盒钉 rows-1 × cols-1(严格小于终端行/列数, | ||
| // 防末行换行顶滚;宽度留 1 列余量,Ink 的截断边界变成 cols-1,决不产出恰满宽行—— | ||
| // =cols 的行会让 iTerm 进入行尾 pending-wrap,>cols 直接物理折行,都会顶滚出残影)。 | ||
| // 历史区贴底渲染+overflow 裁顶:渲染行数恒给足 rows-1 行(≥视口容量),实际可见高度由 flex 决定, | ||
| // 溢出的从顶部裁掉——无需精确计算活区高度。底部活区 flexShrink=0 钉死,滚动只动历史区切片。 | ||
| _jsxs(Box, { flexDirection: "column", height: rows - 1, width: cols, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", justifyContent: "flex-end", children: sliceView(flatLines, scrollOff, rows - 1).map((segs, i) => ( | ||
| _jsxs(Box, { flexDirection: "column", height: rows - 1, width: cols - 1, children: [_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", justifyContent: "flex-end", children: sliceView(flatLines, scrollOff, rows - 1).map((segs, i) => ( | ||
| // 每行套 flexShrink=0 盒钉 1 行高:不钉的话 yoga 会把超高内容**压缩**(隔行丢失)而不是裁顶。 | ||
| _jsx(Box, { flexShrink: 0, children: _jsx(Text, { wrap: "truncate-end", children: segs.length === 0 ? ' ' : segs.map((sg, k) => (_jsx(Text, { color: sg.color, bold: sg.bold, italic: sg.italic, underline: sg.underline, strikethrough: sg.strikethrough, dimColor: sg.dim, children: sg.text }, k))) }) }, i))) }), _jsxs(Box, { ref: bottomBoxRef, flexDirection: "column", flexShrink: 0, children: [scrollOff > 0 ? _jsx(Text, { color: "yellow", children: t().browseHint(scrollOff) }) : null, roster.length ? (_jsx(Text, { children: roster.map((a, i) => (_jsxs(Text, { children: [i ? _jsx(Text, { dimColor: true, children: " \u00B7 " }) : null, _jsxs(Text, { color: color(a.status), children: [statusGlyph(a.status, !!a.virtual, frame), " "] }), _jsx(Text, { color: colorFor(a.name), bold: true, children: a.name }), a.lead ? _jsx(Text, { color: "cyan", bold: true, children: " \u2654" }) : null, a.unresponsive ? _jsx(Text, { color: "red", bold: true, children: " \u26A0" }) : null] }, a.name))) })) : null, pending.length ? (_jsx(Text, { color: "yellow", children: t().pendingDeliver(pending.map((p) => '→ ' + p.to + (p.n > 1 ? ' ×' + p.n : '')).join(' · ')) })) : null, unresp.length ? _jsx(Text, { color: "red", children: t().unresponsiveWarn(unresp) }) : null, hasDiag ? _jsx(Text, { color: "yellow", children: t().diagWarn(drops, injFails, fastIdle) }) : null, todoState && (todoState.state === 'running' || todoState.state === 'paused') ? (() => { | ||
| _jsx(Box, { flexShrink: 0, children: _jsx(Text, { wrap: "truncate-end", children: segs.length === 0 ? ' ' : segs.map((sg, k) => (_jsx(Text, { color: sg.color, bold: sg.bold, italic: sg.italic, underline: sg.underline, strikethrough: sg.strikethrough, dimColor: sg.dim, children: sg.text }, k))) }) }, i))) }), _jsxs(Box, { ref: bottomBoxRef, flexDirection: "column", flexShrink: 0, children: [scrollOff > 0 ? _jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().browseHint(scrollOff) }) : null, roster.length ? (_jsx(Text, { wrap: "truncate-end", children: roster.map((a, i) => (_jsxs(Text, { children: [i ? _jsx(Text, { dimColor: true, children: " \u00B7 " }) : null, _jsxs(Text, { color: color(a.status), children: [statusGlyph(a.status, !!a.virtual, frame), " "] }), _jsx(Text, { color: colorFor(a.name), bold: true, children: a.name }), a.lead ? _jsx(Text, { color: "cyan", bold: true, children: " \u2654" }) : null, a.unresponsive ? _jsx(Text, { color: "red", bold: true, children: " \u26A0" }) : null] }, a.name))) })) : null, pending.length ? (() => { | ||
| // 目标名单按显示宽预截(clipDisp),保住行尾 "Esc 取消排队" 操作提示—— | ||
| // 交给 truncate-end 截行尾的话,超长名单会把提示顶没。truncate-end 仍留作兜底。 | ||
| const budget = Math.max(8, cols - 1 - dispWidth(t().pendingDeliver(''))); | ||
| const targets = pending.map((p) => '→ ' + p.to + (p.n > 1 ? ' ×' + p.n : '')).join(' · '); | ||
| return _jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().pendingDeliver(clipDisp(targets, budget)) }); | ||
| })() : null, unresp.length ? _jsx(Text, { color: "red", wrap: "truncate-end", children: t().unresponsiveWarn(unresp) }) : null, hasDiag ? _jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().diagWarn(drops, injFails, fastIdle) }) : null, todoState && (todoState.state === 'running' || todoState.state === 'paused') ? (() => { | ||
| const cur = todoState.tasks.find((x) => x.status === 'current'); | ||
| const k = todoState.tasks.filter((x) => x.status === 'done' || x.status === 'failed').length + (cur ? 1 : 0); | ||
| return _jsx(Text, { color: "cyan", children: t().todoProgressLine(k, todoState.tasks.length, cur ? String(cur.body).split('\n')[0].slice(0, 60) : '-', todoState.state === 'paused') }); | ||
| })() : todoState && todoState.state === 'idle' && todoState.tasks.length > 0 ? (_jsx(Text, { color: "cyan", children: t().todoPendingLine(todoState.tasks.length) })) : null, confirmExit ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().exitConfirmTitle }), _jsx(Text, { bold: true, children: t().exitConfirmKeys })] })) : langPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().langPickTitle }), LANG_OPTS.map((o, i) => (_jsxs(Text, { inverse: i === langPick, children: [" ", o.label] }, o.v)))] })) : leadPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().leadCmdPickTitle }), leadOpts.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().leadPickEmpty] })) : leadOpts.map((nm, i) => (_jsxs(Text, { inverse: i === leadPick, children: [" ", _jsx(Text, { color: colorFor(nm), bold: true, children: nm })] }, nm)))] })) : qCancel !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().qcancelTitle(queuedMsgs.length) }), queuedMsgs.map((m, i) => (_jsxs(Text, { inverse: i === Math.min(qCancel, Math.max(0, queuedMsgs.length - 1)), wrap: "truncate-end", children: [' → ', _jsx(Text, { color: colorFor(m.to), bold: true, children: m.to }), ' ', String(m.body).replace(/\s+/g, ' ').slice(0, 60)] }, m.id)))] })) : todoView ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().todoListTitle }), !todoState || todoState.tasks.length === 0 ? (_jsxs(Text, { dimColor: true, children: [" ", t().todoListEmpty] })) : todoState.tasks.map((x) => (_jsxs(Text, { children: [" ", x.status === 'done' ? '✅' : x.status === 'failed' ? '❌' : x.status === 'current' ? '▶' : '·', " #", x.seq, " ", String(x.body).split('\n')[0].slice(0, 70), x.result ? ` — ${String(x.result).slice(0, 40)}` : ''] }, x.seq)))] })) : wizard ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: wizard.step === 'cli' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), t().wizardCliSuffix] }), CLIS.map((c, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", c, c === 'codex' ? t().wizardExperimental : ''] }, c)))] })) : wizard.step === 'model' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "]", t().wizardModelSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.modelText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardModelHint })] })) : wizard.step === 'role' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, wizard.model ? '·' + wizard.model : '', "]", t().wizardRoleSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.roleText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, children: t().wizardRoleExample })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "\u00B7", wizard.role, "]", t().wizardCwdSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { children: wizard.path }), _jsx(Text, { inverse: true, children: " " })] }), dirSuggestions(wizard.path, fsListDirs).map((d, i) => (_jsxs(Text, { inverse: i === wizard.sel, children: [" ", d] }, d))), _jsx(Text, { dimColor: true, children: t().wizardCwdDefault })] })) })) : (_jsxs(_Fragment, { children: [answering && pendingQ ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: t().questionAsk(pendingQ.from, pendingQ.question) }), pendingQ.options.map((o, i) => (_jsxs(Text, { inverse: i === qSelClamped, children: [" ", i + 1, ". ", o] }, i))), _jsxs(Text, { inverse: onCustomSlot, dimColor: !onCustomSlot, children: [" ", t().answerCustom] }), customAns !== null ? (_jsxs(Text, { children: [t().answerCustomPrompt, _jsx(Text, { color: "green", children: customAns }), _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { dimColor: true, children: [t().answerKeys, questions.length > 1 ? t().answerMore(questions.length - 1) : '', t().answerOrType] }))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [inputDest.mode === 'cmd' ? _jsx(Text, { color: "magenta", bold: true, children: "\u2318 " }) | ||
| return _jsx(Text, { color: "cyan", wrap: "truncate-end", children: t().todoProgressLine(k, todoState.tasks.length, cur ? String(cur.body).split('\n')[0].slice(0, 60) : '-', todoState.state === 'paused') }); | ||
| })() : todoState && todoState.state === 'idle' && todoState.tasks.length > 0 ? (_jsx(Text, { color: "cyan", wrap: "truncate-end", children: t().todoPendingLine(todoState.tasks.length) })) : null, confirmExit ? (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().exitConfirmTitle }), _jsx(Text, { bold: true, wrap: "truncate-end", children: t().exitConfirmKeys })] })) : langPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().langPickTitle }), LANG_OPTS.map((o, i) => (_jsxs(Text, { inverse: i === langPick, wrap: "truncate-end", children: [" ", o.label] }, o.v)))] })) : leadPick !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().leadCmdPickTitle }), leadOpts.length === 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", t().leadPickEmpty] })) : leadOpts.map((nm, i) => (_jsxs(Text, { inverse: i === leadPick, wrap: "truncate-end", children: [" ", _jsx(Text, { color: colorFor(nm), bold: true, children: nm })] }, nm)))] })) : qCancel !== null ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().qcancelTitle(queuedMsgs.length) }), queuedMsgs.map((m, i) => (_jsxs(Text, { inverse: i === Math.min(qCancel, Math.max(0, queuedMsgs.length - 1)), wrap: "truncate-end", children: [' → ', _jsx(Text, { color: colorFor(m.to), bold: true, children: m.to }), ' ', String(m.body).replace(/\s+/g, ' ').slice(0, 60)] }, m.id)))] })) : todoView ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().todoListTitle }), !todoState || todoState.tasks.length === 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", t().todoListEmpty] })) : todoState.tasks.map((x) => (_jsxs(Text, { wrap: "truncate-end", children: [" ", x.status === 'done' ? '✅' : x.status === 'failed' ? '❌' : x.status === 'current' ? '▶' : '·', " #", x.seq, " ", String(x.body).split('\n')[0].slice(0, 70), x.result ? ` — ${String(x.result).slice(0, 40)}` : ''] }, x.seq)))] })) : wizard ? (_jsx(Box, { flexDirection: "column", marginTop: 1, children: wizard.step === 'cli' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), t().wizardCliSuffix] }), CLIS.map((c, i) => (_jsxs(Text, { inverse: i === wizard.sel, wrap: "truncate-end", children: [" ", c, c === 'codex' ? t().wizardExperimental : ''] }, c)))] })) : wizard.step === 'model' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "]", t().wizardModelPickSuffix] }), MODEL_PRESETS(wizard.cli).map((p, i) => ( | ||
| // custom 是哨兵不是模型名,value 列不显示它(否则 UI 裸露 "custom" 中英混排) | ||
| _jsxs(Text, { inverse: i === wizard.sel, wrap: "truncate-end", children: [" ", p.key === 'custom' ? '' : `${p.value ?? t().wizardModelDefaultLabel} `, t().wizardModelPresets[p.key] ?? ''] }, p.key)))] })) : wizard.step === 'model-custom' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "]", t().wizardModelSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { wrap: "truncate-end", children: wizard.modelText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: t().wizardModelHint })] })) : wizard.step === 'role' ? (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, wizard.model ? '·' + wizard.model : '', "]", t().wizardRoleSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { wrap: "truncate-end", children: wizard.roleText }), _jsx(Text, { inverse: true, children: " " })] }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: t().wizardRoleExample })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [t().wizardAddPrefix, _jsx(Text, { bold: true, children: wizard.name }), " [", wizard.cli, "\u00B7", wizard.role, "]", t().wizardCwdSuffix] }), _jsxs(Box, { children: [_jsx(Text, { color: "green", children: "\u203A " }), _jsx(Text, { wrap: "truncate-end", children: wizard.path }), _jsx(Text, { inverse: true, children: " " })] }), dirSuggestions(wizard.path, fsListDirs).map((d, i) => (_jsxs(Text, { inverse: i === wizard.sel, wrap: "truncate-end", children: [" ", d] }, d))), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: t().wizardCwdDefault })] })) })) : (_jsxs(_Fragment, { children: [answering && pendingQ ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", wrap: "truncate-end", children: t().questionAsk(pendingQ.from, pendingQ.question) }), pendingQ.options.map((o, i) => (_jsxs(Text, { inverse: i === qSelClamped, wrap: "truncate-end", children: [" ", i + 1, ". ", o] }, i))), _jsxs(Text, { inverse: onCustomSlot, dimColor: !onCustomSlot, wrap: "truncate-end", children: [" ", t().answerCustom] }), customAns !== null ? (_jsxs(Text, { wrap: "truncate-end", children: [t().answerCustomPrompt, _jsx(Text, { color: "green", children: customAns }), _jsx(Text, { inverse: true, children: " " })] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [t().answerKeys, questions.length > 1 ? t().answerMore(questions.length - 1) : '', t().answerOrType] }))] })) : null, _jsx(Box, { marginTop: 1, children: _jsxs(Text, { children: [inputDest.mode === 'cmd' ? _jsx(Text, { color: "magenta", bold: true, children: "\u2318 " }) | ||
| : inputDest.mode === 'broadcast' ? _jsxs(Text, { color: "yellow", bold: true, children: ["\uD83D\uDCE2 ", t().clearAll, " "] }) | ||
| : inputDest.mode === 'to' ? _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "\u2192 " }), _jsx(Text, { color: colorFor(inputDest.to), bold: true, children: inputDest.to }), _jsx(Text, { children: " " })] }) | ||
| : _jsxs(Text, { color: "yellow", bold: true, children: [t().noReplyTargetShort, " "] }), _jsx(Text, { color: "green", children: "\u203A " }), input.slice(0, cursor), _jsx(Text, { inverse: true, children: input[cursor] && input[cursor] !== '\n' ? input[cursor] : ' ' }), input.slice(cursor + 1)] }) }), active ? (_jsx(Box, { flexDirection: "column", children: items.map((it, i) => (_jsxs(Text, { inverse: i === selClamped, children: [" ", it.label, it.hint ? ' ' + it.hint : ''] }, it.label))) })) : (_jsx(Text, { dimColor: true, children: t().inputHint(replyTarget ?? t().noReplyTargetShort) }))] })), status ? _jsx(Text, { dimColor: true, children: status }) : null] })] })); | ||
| : _jsxs(Text, { color: "yellow", bold: true, children: [t().noReplyTargetShort, " "] }), _jsx(Text, { color: "green", children: "\u203A " }), input.slice(0, cursor), _jsx(Text, { inverse: true, children: input[cursor] && input[cursor] !== '\n' ? input[cursor] : ' ' }), input.slice(cursor + 1)] }) }), active ? (_jsx(Box, { flexDirection: "column", children: items.map((it, i) => (_jsxs(Text, { inverse: i === selClamped, wrap: "truncate-end", children: [" ", it.label, it.hint ? ' ' + it.hint : ''] }, it.label))) })) : (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: t().inputHint(replyTarget ?? t().noReplyTargetShort) }))] })), status ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: status }) : null] })] })); | ||
| } |
@@ -8,2 +8,7 @@ import { t } from '../i18n/index.js'; | ||
| if (s.startsWith('/')) { | ||
| // 路径不是命令:首 token 里还有第二个 "/"(如 /var/folders/... 或 /a/b)→ 按普通文本走回复, | ||
| // 别让粘贴的文件路径/绝对路径吃「未知命令」。真命令(/todo、/restart…)首 token 不含第二个斜杠。 | ||
| const firstTok = s.slice(1).split(/\s+/, 1)[0] ?? ''; | ||
| if (firstTok.includes('/')) | ||
| return { kind: 'reply', message: s }; | ||
| const [cmd, ...args] = s.slice(1).split(/\s+/); | ||
@@ -10,0 +15,0 @@ if (cmd === 'help') |
@@ -43,2 +43,23 @@ /** 把 log 中 id 未见过的消息按顺序追加到 committed(已提交项引用不变);无新增则返回原数组。 | ||
| return 2; | ||
| // Unicode East Asian Wide(EAW=W)的 BMP 符号单字(⌚⏳✅❌⭐ 等 emoji 表现型): | ||
| // Ink 的 string-width 与 iTerm 都按 2 列渲染,漏算会让 wrapSegs 超额打包出真实超宽行 | ||
| // (→ 物理折行 → 顶滚残影)。只列 EAW=W 的显式区段,不可把 2300-27BF 整段按宽算—— | ||
| // 同区的 ⚠(26A0)/▶(25B6)等 EAW=N/A 字符 iTerm 默认渲 1 列。 | ||
| if (cp === 0x231a || cp === 0x231b || // ⌚⌛ | ||
| (cp >= 0x23e9 && cp <= 0x23ec) || cp === 0x23f0 || cp === 0x23f3 || // ⏩-⏬ ⏰ ⏳ | ||
| cp === 0x25fd || cp === 0x25fe || // ◽◾ | ||
| cp === 0x2614 || cp === 0x2615 || // ☔☕ | ||
| (cp >= 0x2648 && cp <= 0x2653) || // 星座 ♈-♓ | ||
| cp === 0x267f || cp === 0x2693 || cp === 0x26a1 || // ♿⚓⚡ | ||
| cp === 0x26aa || cp === 0x26ab || cp === 0x26bd || cp === 0x26be || // ⚪⚫⚽⚾ | ||
| cp === 0x26c4 || cp === 0x26c5 || cp === 0x26ce || cp === 0x26d4 || // ⛄⛅⛎⛔ | ||
| cp === 0x26ea || cp === 0x26f2 || cp === 0x26f3 || cp === 0x26f5 || // ⛪⛲⛳⛵ | ||
| cp === 0x26fa || cp === 0x26fd || // ⛺⛽ | ||
| cp === 0x2705 || cp === 0x270a || cp === 0x270b || cp === 0x2728 || // ✅✊✋✨ | ||
| cp === 0x274c || cp === 0x274e || // ❌❎ | ||
| (cp >= 0x2753 && cp <= 0x2755) || cp === 0x2757 || // ❓❔❕❗ | ||
| (cp >= 0x2795 && cp <= 0x2797) || cp === 0x27b0 || cp === 0x27bf || // ➕➖➗➰➿ | ||
| cp === 0x2b1b || cp === 0x2b1c || cp === 0x2b50 || cp === 0x2b55 // ⬛⬜⭐⭕ | ||
| ) | ||
| return 2; | ||
| return 1; | ||
@@ -53,2 +74,19 @@ } | ||
| } | ||
| /** 按显示列宽截断:超出 width 时截到 width-1 列并补 "…"(不劈半个宽字)。 | ||
| * 用于"中段可截、尾部提示必须保住"的单行 UI(如等送达行的 Esc 提示)—— | ||
| * Ink 的 wrap="truncate-end" 只能截行尾,保不住后缀。 */ | ||
| export function clipDisp(s, width) { | ||
| if (width <= 0 || dispWidth(s) <= width) | ||
| return s; | ||
| let out = ''; | ||
| let used = 0; | ||
| for (const ch of s) { | ||
| const w = cpWidth(ch.codePointAt(0)); | ||
| if (used + w > width - 1) | ||
| break; | ||
| out += ch; | ||
| used += w; | ||
| } | ||
| return out + '…'; | ||
| } | ||
| /** | ||
@@ -55,0 +93,0 @@ * 把一行样式片段按显示列宽折成多行(贪婪逐码点,CJK=2 列),样式跟随片段不丢。 |
| import { readdirSync } from 'node:fs'; | ||
| /** 可选的 CLI 类型(向导里枚举给用户选)。 */ | ||
| export const CLIS = ['claude', 'codex']; | ||
| export function MODEL_PRESETS(cli) { | ||
| const base = [{ value: undefined, key: 'default' }]; | ||
| if (cli === 'claude') { | ||
| base.push({ value: 'opus[1m]', key: 'opus1m' }, { value: 'opus', key: 'opus' }, { value: 'sonnet[1m]', key: 'sonnet1m' }, { value: 'sonnet', key: 'sonnet' }, { value: 'haiku', key: 'haiku' }); | ||
| } | ||
| base.push({ value: 'custom', key: 'custom' }); | ||
| return base; | ||
| } | ||
| /** 列出 base 目录下的子目录名(用于路径补全;读失败返回空)。 */ | ||
@@ -5,0 +13,0 @@ export function fsListDirs(base) { |
@@ -111,2 +111,3 @@ export class Router { | ||
| a.unresponsive = false; | ||
| a.unresponsiveRule = undefined; | ||
| a.muteStreak = 0; | ||
@@ -134,4 +135,4 @@ } | ||
| } | ||
| /** 标失联嫌疑。返回是否为新标记(边沿触发:调用方据此只落一次诊断)。虚拟成员排除(boss 从不调 MCP 工具)。 */ | ||
| markUnresponsive(name) { | ||
| /** 标失联嫌疑(rule 记录触发规则,决定警告文案分流)。返回是否为新标记(边沿触发:调用方据此只落一次诊断)。虚拟成员排除(boss 从不调 MCP 工具)。 */ | ||
| markUnresponsive(name, rule) { | ||
| const a = this.agents.get(name); | ||
@@ -141,2 +142,3 @@ if (!a || a.virtual || a.unresponsive) | ||
| a.unresponsive = true; | ||
| a.unresponsiveRule = rule; | ||
| return true; | ||
@@ -153,2 +155,3 @@ } | ||
| a.unresponsive = false; | ||
| a.unresponsiveRule = undefined; | ||
| a.muteStreak = 0; | ||
@@ -155,0 +158,0 @@ a.lastMcpAt = undefined; |
+14
-3
@@ -35,4 +35,4 @@ /** 英文词典:typeof zh 钉死 key 与签名——漏译/签名不符直接编译失败。 */ | ||
| unresponsiveWarn: (items) => '⚠ ' + | ||
| items.map((i) => `${i.name}${i.mcpSeen ? ' (MCP connected but never registered; session may be wedged)' : ' (CLI likely missing falinks MCP config; manually restarted?)'}`).join(', ') + | ||
| ' — try /restart <name> [fresh]', | ||
| items.map((i) => `${i.name}${i.rule === 'mute' ? ' (got messages but made zero tool calls — session may be wedged, e.g. context exhausted)' : i.mcpSeen ? ' (MCP connected but never registered; session may be wedged)' : ' (CLI likely missing falinks MCP config; manually restarted?)'}`).join(', ') + | ||
| (items.some((i) => i.rule === 'mute') ? ' — try /restart <name> fresh' : ' — try /restart <name> [fresh]'), | ||
| clearAll: 'everyone', | ||
@@ -52,3 +52,14 @@ clearNone: 'none', | ||
| wizardModelSuffix: ' — model (Enter next · blank = CLI default · Esc cancel)', | ||
| wizardModelHint: 'e.g. claude-opus-4-8 / claude-fable-5. Blank = CLI global default; a wrong name fails to launch and trips the ⚠ no-register alarm.', | ||
| wizardModelHint: 'e.g. claude-opus-4-8 / claude-opus-4-8[1m] (1M context). Blank = CLI global default; a wrong name fails to launch and trips the ⚠ no-register alarm.', | ||
| wizardModelPickSuffix: ' — pick a model (↑↓ select · Enter confirm · Esc cancel)', | ||
| wizardModelDefaultLabel: '(default)', | ||
| wizardModelPresets: { | ||
| default: 'Default (follow CLI global setting)', | ||
| opus1m: 'Opus 4.8 · 1M context', | ||
| opus: 'Opus 4.8', | ||
| sonnet1m: 'Sonnet 4.6 · 1M context', | ||
| sonnet: 'Sonnet 4.6', | ||
| haiku: 'Haiku 4.5 · fast/cheap', | ||
| custom: 'Custom… (type a model name)', | ||
| }, | ||
| wizardRoleSuffix: ' — role/duties (Enter next · Esc cancel)', | ||
@@ -55,0 +66,0 @@ wizardRoleExample: 'e.g. backend dev / code review / research. Leave empty = generic worker.', |
+15
-4
@@ -34,6 +34,6 @@ /** 中文基准词典:全部用户可见/注入员工的文案都从这里出。纯文本=字符串,带变量=箭头函数。 */ | ||
| restartBusy: (n) => `${n} 正在重启/清空中,稍后再试`, | ||
| // 失联警告行:按"是否连过 MCP"分流两种病因文案。 | ||
| // 失联警告行:先按触发规则分流(mute=有活无声),register-timeout 再按"是否连过 MCP"分流两种病因文案。 | ||
| unresponsiveWarn: (items) => '⚠ ' + | ||
| items.map((i) => `${i.name}${i.mcpSeen ? '(MCP 连过但未报到,会话可能瘫痪)' : '(CLI 可能没挂 falinks 工具,手动重启过?)'}`).join('、') + | ||
| ' —— 试试 /restart <名字> [fresh]', | ||
| items.map((i) => `${i.name}${i.rule === 'mute' ? '(收到消息却全程零工具调用——会话可能瘫痪,如上下文耗尽)' : i.mcpSeen ? '(MCP 连过但未报到,会话可能瘫痪)' : '(CLI 可能没挂 falinks 工具,手动重启过?)'}`).join('、') + | ||
| (items.some((i) => i.rule === 'mute') ? ' —— 试试 /restart <名字> fresh' : ' —— 试试 /restart <名字> [fresh]'), | ||
| clearAll: '全员', | ||
@@ -53,3 +53,14 @@ clearNone: '无', | ||
| wizardModelSuffix: ' — 模型(Enter 下一步 · 留空=CLI 默认 · Esc 取消)', | ||
| wizardModelHint: '例:claude-opus-4-8 / claude-fable-5。留空用 CLI 全局默认;填错会启动失败并触发 ⚠ 未报到告警。', | ||
| wizardModelHint: '例:claude-opus-4-8 / claude-opus-4-8[1m](1M 上下文)。留空用 CLI 全局默认;填错会启动失败并触发 ⚠ 未报到告警。', | ||
| wizardModelPickSuffix: ' — 选模型(↑↓ 选 · Enter 确认 · Esc 取消)', | ||
| wizardModelDefaultLabel: '(默认)', | ||
| wizardModelPresets: { | ||
| default: '默认(跟随 CLI 全局设置)', | ||
| opus1m: 'Opus 4.8 · 1M 上下文', | ||
| opus: 'Opus 4.8', | ||
| sonnet1m: 'Sonnet 4.6 · 1M 上下文', | ||
| sonnet: 'Sonnet 4.6', | ||
| haiku: 'Haiku 4.5 · 快/省', | ||
| custom: '自定义…(手动输入模型名)', | ||
| }, | ||
| wizardRoleSuffix: ' — 角色/职责(Enter 下一步 · Esc 取消)', | ||
@@ -56,0 +67,0 @@ wizardRoleExample: '例:负责后端开发 / 审查代码 / 调研查证。留空=通用员工。', |
+1
-1
@@ -79,3 +79,3 @@ import { readFileSync, mkdtempSync, writeFileSync, mkdirSync, realpathSync, rmSync } from 'node:fs'; | ||
| const alarmUnresponsive = (name, rule) => { | ||
| if (!router.markUnresponsive(name)) | ||
| if (!router.markUnresponsive(name, rule)) | ||
| return; | ||
@@ -82,0 +82,0 @@ try { |
+1
-1
| { | ||
| "name": "@hklmtt/falinks", | ||
| "version": "0.12.0", | ||
| "version": "0.12.1", | ||
| "description": "把多个终端 AI CLI(Claude Code、Codex…)编排成一间办公室:分屏真实窗口里的员工,通过 MCP 总线互相对话协作,外加一个控制台。", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
+1
-1
@@ -31,3 +31,3 @@ ``` | ||
| - **Add/remove workers at runtime** (console `/add` wizard), **`/clear` a worker's context** (identity auto-restored after), **auto-offline on window close**, **runaway guards** (turn cap / loop detection / throttle), and a **token-saving collaboration rule** (no pleasantries). | ||
| - **Per-agent model**: each worker can pin its own model (`model` in the config, or the `/add` wizard's model step — leave blank for the CLI default); passed as `claude --model` / `codex -m` and kept across restart/resume. | ||
| - **Per-agent model**: each worker can pin its own model (`model` in the config, or the `/add` wizard's model step — leave blank for the CLI default); passed as `claude --model` / `codex -m` and kept across restart/resume. Append Claude Code's `[1m]` suffix (e.g. `claude-opus-4-8[1m]`) for the 1M-context variant. | ||
| - **`/restart <name> [fresh]`**: relaunch a worker's CLI with the correct MCP config (`fresh` = brand-new session) — the right way to restart, instead of manually rerunning a CLI in the pane. | ||
@@ -34,0 +34,0 @@ - **Unresponsive detection**: a worker that never registers (90s timeout) or goes silent (2× the auto-idle window) gets a red ⚠ badge in the roster plus a console warning, and self-heals on its next MCP call. |
311523
3.14%5153
2.24%