mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ac13da1a | ||
|
|
48f30f3055 | ||
|
|
c211461500 | ||
|
|
37929cb671 | ||
|
|
e0ebbbdc91 | ||
|
|
8c237223b0 | ||
|
|
dae2ac580f | ||
|
|
7767b16d4f | ||
|
|
0cedf05d13 | ||
|
|
58b4cb06d8 |
@@ -1,5 +1,77 @@
|
||||
# aicodeman
|
||||
|
||||
## 1.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 58b4cb0: feat(files): let the path picker jump to a typed path and sort by name or date
|
||||
|
||||
The picker's current-folder line was read-only, so reaching a deep folder meant tapping
|
||||
through every level, and its listing was fixed to name order, so the file an agent had
|
||||
just written was somewhere in a 500-entry list. The current folder is now an editable
|
||||
field (Enter or Go jumps there, a full file path lands in its folder with the file
|
||||
selected, and a typo keeps the listing you had instead of resetting to the root), the
|
||||
listing can be sorted by name or modified time in either direction with folders always
|
||||
first (the choice is remembered per device), and each entry shows a compact modified
|
||||
time. `GET /api/filesystem/browse` entries carry `mtimeMs` to make that possible, with
|
||||
one stat per entry.
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- c211461: fix(terminal): swallow Ctrl+Z in agent sessions so it cannot suspend a running CLI
|
||||
|
||||
Ctrl+Z raises SIGTSTP on the pane's tty. In a `shell` session that is ordinary job control and
|
||||
is left alone, but in an agent session suspending the CLI stops an unattended loop dead with no
|
||||
visible output, the same failure shape as an XOFF freeze. The key is now swallowed in
|
||||
`attachCustomKeyEventHandler` for every non-shell mode, and unconditionally in the
|
||||
subagent/teammate terminals, which always run an agent CLI. The match is case-insensitive,
|
||||
because Caps Lock flips `ev.key` to `'Z'` without setting `shiftKey` and a plain `=== 'z'`
|
||||
check would let exactly the keystroke this exists to catch through.
|
||||
|
||||
This is defence in depth rather than a fix for the steady state: an agent CLI holds its tty in
|
||||
raw mode with ISIG off, where ^Z is already inert. It covers the moments that are not the
|
||||
steady state: the window before the CLI takes the tty at startup, and any point where it hands
|
||||
the tty back. Two input paths are deliberately not covered and still reach the PTY: the mobile
|
||||
keyboard accessory bar's one-shot Ctrl, and the CJK composition textarea when `cjkInputEnabled`
|
||||
is on. Both are separate choke points to the PTY, and both are worth covering if this ever
|
||||
turns out to matter in practice.
|
||||
|
||||
- 7767b16: fix(terminal): let Claude use truecolor so its themed backgrounds render
|
||||
|
||||
Claude draws the user's own messages as a block of background color, and it renders as an
|
||||
approximation of the theme color at best. Claude's registry entry deleted `COLORTERM`, which
|
||||
left it the only agent CLI here besides `opencode` not asking for 24-bit color, so every RGB
|
||||
color its theme asks for was quantized down to whatever palette `TERM` alone implies. Claude
|
||||
now exports `COLORTERM=truecolor` like codex, gemini, antigravity, pi, grok, deepseek and omp
|
||||
already do, and the block renders in the color the theme actually names.
|
||||
|
||||
How bad the quantization was depends on `TERM`, which is why this looks different on different
|
||||
machines. On tmux 3.2 and newer, whose `default-terminal` defaults to `tmux-256color`,
|
||||
supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but
|
||||
not the color the theme asked for. Where `TERM` resolves to a 16-color entry instead (tmux
|
||||
older than 3.2, or a `~/.tmux.conf` setting `default-terminal screen`, which Codeman's tmux
|
||||
server does read), every dark background collapses to `ESC[40m`, the terminal's own black, and
|
||||
the block disappears entirely. That is the case this was reported from, and a custom Claude
|
||||
theme could change the color there with nothing on screen moving.
|
||||
|
||||
Those seven CLIs also unset `NO_COLOR`; Claude does not, so a user who exports `NO_COLOR`
|
||||
globally keeps the monochrome panes they asked for. `CLAUDECODE` stays unset, because Claude
|
||||
reads it as a signal that it is running nested inside itself.
|
||||
|
||||
`buildClaudeEnv()`, the direct-PTY fallback used when tmux is unavailable, now reads the same
|
||||
registry entry as the tmux pane and its attach client instead of deleting `COLORTERM` from a
|
||||
hand-maintained list of its own. It applies that entry before assigning Codeman's own
|
||||
variables, mirroring `buildEnvExports()`, so a `clis.json` override naming one of them cannot
|
||||
strip it on this path while the tmux pane keeps it. A remote pane still exports nothing,
|
||||
because `buildRemoteLaunchCommand()` never carried these declarations, so an SSH-remote Claude
|
||||
session keeps the old rendering.
|
||||
|
||||
PR #3 introduced the `unset COLORTERM` in February, citing xterm.js#484 for the claim that
|
||||
xterm.js mishandles truecolor, and aiming to fall back to 256-color mode. xterm.js closed that
|
||||
issue in April 2019, Codeman now depends on `@xterm/xterm` 6, and `TmuxManager` sets
|
||||
`terminal-overrides ",*:Tc"` on its own tmux server, so 24-bit color already reaches the
|
||||
browser for the CLIs that ask for it.
|
||||
|
||||
## 1.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -18,9 +18,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
||||
|
||||
## Session launch modes
|
||||
|
||||
**Terminal colour env** — the stock registry decides each CLI's colour vars. Claude, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor`; `shell` and `opencode` unset it. All of those except Claude also unset `NO_COLOR`, so a user who exports `NO_COLOR` globally keeps monochrome Claude panes. The variable matters because a CLI inheriting no `COLORTERM` quantizes every RGB color it draws down to whatever palette `TERM` alone implies, and the pane's `TERM` is not a constant. ⚠️ Codeman sets no `default-terminal` and passes tmux no `-f`, so it is tmux's default (`tmux-256color` since 3.2) unless the user's own `~/.tmux.conf` says otherwise, which Codeman's server DOES read. At `tmux-256color` supports-color reports 256 colors and `rgb(55, 55, 55)` lands on `ESC[48;5;237m`: visible, but not the color the theme named. Where `TERM` resolves to a 16-color entry instead (tmux older than 3.2, or a conf setting `default-terminal screen`) every dark background collapses to `ESC[40m`, the terminal's own black, and the block disappears entirely. That is why the same Claude theme looks different on two machines, and why a bug report here is worth pairing with the reporter's `tmux -V` and their pane's real `TERM`. ⚠️ These declarations reach the local tmux pane via `buildEnvExports()`, its attach client via `cliExportsTruecolor()`, and the direct-PTY fallback via `buildClaudeEnv()` — they do NOT reach a remote pane, which `buildRemoteLaunchCommand()` builds with no env exports at all. A Docker pane takes `COLORTERM=truecolor` from the hardcoded `envCreate`/`execEnv` in `tmux-manager.ts`, which apply to every mode including the two the registry says must unset it. A `~/.codeman/clis.json` override replaces these arrays wholesale (`deepMerge`), so a custom entry can drop either list. That is why both consumers apply the entry BEFORE Codeman's own variables rather than after: `buildEnvExports()` emits `...cliEnv` ahead of `export CODEMAN_MUX=1`, and `buildClaudeEnv()` assigns `PATH`/`TERM`/`CODEMAN_*` after its unset/export loop. Reversed, a config-supplied `unset` naming `CODEMAN_HOOK_SECRET_FILE` would strip it on one path and not the other.
|
||||
|
||||
### External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)
|
||||
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
|
||||
**External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini' || 'antigravity' || 'pi' || 'grok' || 'deepseek'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). ⚠️ **Work detection left this gate in #385** and is now per-CLI `capabilities.workDetect` data (`promptGlyph` + `workingLine`), because gating it on the mode left every Codex session reporting `idle` for its entire life; a CLI declaring neither falls back to Claude's pair, which is logic-identical to the pre-registry behaviour. All seven modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume <id>`, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode <default|auto_edit|yolo|plan>` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex, Gemini, Antigravity, Pi, Grok, DeepSeek and OMP export `COLORTERM=truecolor` and unset `NO_COLOR`; `opencode` unsets `COLORTERM`. **Terminal colour env** under Session launch modes covers Claude and says which panes those declarations actually reach. Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Antigravity specifics: command built by `buildAntigravityCommand()` (`--model`, `--conversation <id>` resume, `--dangerously-skip-permissions` from the `antigravityConfig` payload); availability via `GET /api/antigravity/status` — routes fail with `OPERATION_FAILED` + install hint (`curl -fsSL https://antigravity.google/cli/install.sh | bash`) when missing. Unlike the other three it is NOT an npm package (standalone binary, `~/.local/bin/agy`), which is why `docker/agent.Dockerfile` installs it with its own `--dir /usr/local/bin` step rather than in the `npm install -g` line, and why it does NOT join `isAltScreenStripMode()`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Agents & CLIs → Codex; Respawn/Ralph options are Claude-only, so session options open on the Session tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). Grok specifics: command built by `buildGrokCommand()` (`--always-approve` from `grokConfig.alwaysApprove` — grok's `bypassPermissions` permission mode, deny rules still apply; `--model`; `--resume <id>` / `--continue`, id-regexed so grok's resume-by-TITLE feature can never put an arbitrary string on the spawn line); availability via `GET /api/grok/status`, which carries `version` because the resolver version-probes candidates (`grok` has npm squatters, e.g. @vibe-kit/grok-cli — `GROK_VERSION_REGEX` is shared with the dependency registry so doctor and run mode agree). Like antigravity it is a standalone binary (xAI installer → `~/.grok/bin`, symlinked into `~/.local/bin`), so `docker/agent.Dockerfile` installs it in its own step (copy to `/usr/local/bin`, drop root's `~/.grok` in the same layer) and it stays OUT of `isAltScreenStripMode()` (fullscreen alt-screen TUI with mouse support — the opencode case, not the Ink case). Env allowlist: `GROK_*` plus the vendor namespace `XAI_*` (`XAI_API_KEY` is grok's documented headless auth var — the same narrow-vendor-namespace reasoning as `GOOGLE_*` for gemini). Docker cred seeding is per-file (`auth.json`, `config.toml`, `pager.toml` from `~/.grok` — the dir also holds `sessions/`, `memory/`, and the ~160MB binary under `downloads/`). Grok tests: `test/grok-mode.test.ts`, `test/grok-cli-resolver.test.ts`.
|
||||
|
||||
**DeepSeek Harness (`dsh`) specifics** — the mode that breaks three of the assumptions the six above share, so read this before changing anything about it.
|
||||
|
||||
|
||||
@@ -117,10 +117,14 @@ For choosing a path rather than typing one. It appears in two places:
|
||||
- **Browse** in **Add Case → Link Existing**.
|
||||
- The **📁 Path** key on the mobile keyboard bar.
|
||||
|
||||
It browses one directory at a time and can show hidden entries on request. The picker
|
||||
inserts the path into your prompt **without** pressing Enter, so nothing is submitted by
|
||||
accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's
|
||||
`/clear` command.
|
||||
It browses one directory at a time and can show hidden entries on request. The current
|
||||
folder is an editable field: type or paste a path and press Enter (or **Go**) to jump
|
||||
straight there, and a full file path lands in its folder with that file selected. The
|
||||
**Sort** control orders each listing by name or by modified time (newest first is the
|
||||
quick way to the file an agent just wrote), with folders always ahead of files; the
|
||||
choice is remembered per device. The picker inserts the path into your prompt
|
||||
**without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All**
|
||||
key clears the unsent prompt, and never sends the agent's `/clear` command.
|
||||
|
||||
This is a separate file-serving surface from the viewer, with its own rules: it allowlists
|
||||
your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "1.27.0",
|
||||
"version": "1.28.0",
|
||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -174,8 +174,19 @@ const CLAUDE: CliEntry = {
|
||||
legacyConfigAliases: { resumeId: 'resumeSessionId' },
|
||||
},
|
||||
env: {
|
||||
exports: [],
|
||||
unset: ['CLAUDECODE', 'COLORTERM'],
|
||||
// Claude asks for truecolor, like every CLI here except `shell` and `opencode`.
|
||||
// tmux hands the pane TERM=screen, which supports-color reads as 16 colors, and
|
||||
// Claude then quantizes every RGB color its theme asks for down to that palette.
|
||||
// Each dark background lands on ESC[40m, the terminal's own black, so the block
|
||||
// Claude draws behind the user's own messages renders invisible. PR #3 unset
|
||||
// COLORTERM here against xterm.js#484, which xterm.js had already closed in 2019,
|
||||
// and Codeman now ships @xterm/xterm 6 and sets `terminal-overrides *:Tc` itself.
|
||||
// The other truecolor CLIs also unset NO_COLOR. Claude does not, so a user who
|
||||
// exports NO_COLOR globally keeps the monochrome panes they asked for.
|
||||
// CLAUDECODE stays unset, because Claude reads it as a signal that it is running
|
||||
// nested inside itself.
|
||||
exports: [{ name: 'COLORTERM', value: 'truecolor' }],
|
||||
unset: ['CLAUDECODE'],
|
||||
tmuxSetenvKeys: [],
|
||||
dockerExecEnvNames: [],
|
||||
allowedPrefixes: ['CLAUDE_CODE_'],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { isEffortLevel } from './types.js';
|
||||
import { getAugmentedPath } from './utils/index.js';
|
||||
import { compareVersions } from './utils/dependency-checker.js';
|
||||
import { dataPath } from './config/instance.js';
|
||||
import { getCli } from './config/cli-registry/registry.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
@@ -169,6 +170,36 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
};
|
||||
|
||||
// The colour and identity vars come from the registry entry, the same source
|
||||
// buildEnvExports() and buildMuxAttachEnv() read, so this fallback cannot drift from
|
||||
// the tmux pane the way a hand-maintained list here did.
|
||||
// ⚠️ This block runs BEFORE Codeman's own keys are assigned, mirroring
|
||||
// buildEnvExports(), where `...cliEnv` is emitted ahead of `export CODEMAN_MUX=1`.
|
||||
// Applied afterwards it would outrank them: `unset` and `exports` are config
|
||||
// (`~/.codeman/clis.json` overrides any entry), so an entry naming
|
||||
// CODEMAN_HOOK_SECRET_FILE or PATH would strip or rewrite it on this path while the
|
||||
// tmux pane, where Codeman's exports come last, kept its own value.
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
const cliEnv = getCli('claude')?.env;
|
||||
for (const name of cliEnv?.unset ?? []) delete env[name];
|
||||
for (const item of cliEnv?.exports ?? []) {
|
||||
// A direct PTY has no mux, so `muxName` has no value to resolve against. Claude
|
||||
// declares literals only; an unresolvable engine value is skipped, never guessed.
|
||||
const value =
|
||||
typeof item.value === 'string'
|
||||
? item.value
|
||||
: item.value.engine === 'sessionId'
|
||||
? sessionId
|
||||
: item.value.engine === 'codemanPrefixedSessionId'
|
||||
? `codeman_${sessionId}`
|
||||
: undefined;
|
||||
if (value !== undefined) env[item.name] = value;
|
||||
}
|
||||
|
||||
Object.assign(env, {
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
@@ -180,11 +211,7 @@ export function buildClaudeEnv(sessionId: string): Record<string, string | undef
|
||||
// as the literal "CODEMAN_API_URL=undefined" (COD-115).
|
||||
// Path only (not the secret value) — hook curls cat it at execution time (COD-54)
|
||||
CODEMAN_HOOK_SECRET_FILE: dataPath('hook-secret'),
|
||||
};
|
||||
// COD-115: `delete`, not `= undefined` — node-pty serializes a present-with-undefined
|
||||
// key as the literal string "KEY=undefined" (see buildMuxAttachEnv below).
|
||||
delete env.COLORTERM;
|
||||
delete env.CLAUDECODE;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface FilesystemBrowseEntry {
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
/** Last-modified time (ms since epoch) of the entry's target; lets the picker sort by date. */
|
||||
mtimeMs?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
@@ -47,11 +47,24 @@
|
||||
// the picker browses Home and every configured root, so wanting dotfiles in a
|
||||
// project does not imply wanting them in ~.
|
||||
const PATH_PICKER_SHOW_HIDDEN_KEY = 'codeman:pathPickerShowHidden';
|
||||
// Per-device like the hidden toggle: how you scan a folder is a habit of the
|
||||
// hand, not of the workspace.
|
||||
const PATH_PICKER_SORT_KEY = 'codeman:pathPickerSort';
|
||||
const PATH_PICKER_SORT_MODES = [
|
||||
{ value: 'name-asc', label: 'Name A→Z' },
|
||||
{ value: 'name-desc', label: 'Name Z→A' },
|
||||
{ value: 'mtime-desc', label: 'Newest first' },
|
||||
{ value: 'mtime-asc', label: 'Oldest first' },
|
||||
];
|
||||
const PATH_PICKER_DEFAULT_SORT = 'name-asc';
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_currentPath: '',
|
||||
_entries: [],
|
||||
_truncated: false,
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
@@ -59,6 +72,7 @@ const PathPicker = {
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
_showHidden: false,
|
||||
_sortMode: PATH_PICKER_DEFAULT_SORT,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
@@ -69,7 +83,10 @@ const PathPicker = {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._currentPath = '';
|
||||
this._entries = [];
|
||||
this._showHidden = this._loadShowHidden();
|
||||
this._sortMode = this._loadSortMode();
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
@@ -90,11 +107,19 @@ const PathPicker = {
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<form class="path-picker-jump" title="Current folder — edit and press Enter to jump">
|
||||
<input type="text" class="path-picker-current" aria-label="Current folder path" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" enterkeyhint="go">
|
||||
<button type="submit" class="path-picker-go" title="Go to this path" aria-label="Go to this path">Go</button>
|
||||
</form>
|
||||
<button type="button" class="path-picker-hidden" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false">.*</button>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-toolbar">
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<label class="path-picker-sort-label">Sort
|
||||
<select class="path-picker-sort" aria-label="Sort entries"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
@@ -114,12 +139,27 @@ const PathPicker = {
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
if (this._currentPath) this.select(this._currentPath);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load(this._currentPath));
|
||||
overlay.querySelector('.path-picker-hidden').addEventListener('click', () => this.toggleHidden());
|
||||
this._syncHiddenButton();
|
||||
// Typing a path is the fast way there. The listing is loaded ONLY on Enter/Go,
|
||||
// never on each keystroke: a half-typed path is a 404 the server has to
|
||||
// answer for nothing, and jumping mid-edit would yank the field around.
|
||||
overlay.querySelector('.path-picker-jump').addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
this.jumpTo(overlay.querySelector('.path-picker-current').value);
|
||||
});
|
||||
const sortSelect = overlay.querySelector('.path-picker-sort');
|
||||
for (const mode of PATH_PICKER_SORT_MODES) {
|
||||
const option = document.createElement('option');
|
||||
option.value = mode.value;
|
||||
option.textContent = mode.label;
|
||||
sortSelect.appendChild(option);
|
||||
}
|
||||
sortSelect.value = this._sortMode;
|
||||
sortSelect.addEventListener('change', (event) => this.setSortMode(event.target.value));
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
@@ -169,15 +209,97 @@ const PathPicker = {
|
||||
// OFF inside a hidden folder makes the current path unbrowsable again; the
|
||||
// server answers 403 and load()'s catch falls back to the default root,
|
||||
// which is the only place left to stand.
|
||||
this.load(this.overlay.querySelector('.path-picker-current').textContent || '');
|
||||
this.load(this._currentPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
_loadSortMode() {
|
||||
try {
|
||||
const stored = localStorage.getItem(PATH_PICKER_SORT_KEY);
|
||||
return PATH_PICKER_SORT_MODES.some((mode) => mode.value === stored) ? stored : PATH_PICKER_DEFAULT_SORT;
|
||||
} catch {
|
||||
return PATH_PICKER_DEFAULT_SORT;
|
||||
}
|
||||
},
|
||||
|
||||
setSortMode(mode) {
|
||||
if (!PATH_PICKER_SORT_MODES.some((candidate) => candidate.value === mode)) return;
|
||||
this._sortMode = mode;
|
||||
try {
|
||||
localStorage.setItem(PATH_PICKER_SORT_KEY, mode);
|
||||
} catch {}
|
||||
const select = this.overlay?.querySelector('.path-picker-sort');
|
||||
if (select && select.value !== mode) select.value = mode;
|
||||
// Re-order what is already on screen; no round trip, no lost selection.
|
||||
if (this.overlay) this.renderEntries();
|
||||
},
|
||||
|
||||
/**
|
||||
* Order entries for display. Folders always come first, whatever the mode:
|
||||
* a date sort is for finding the file you just made, and the folders are the
|
||||
* way past it, not the thing being looked for. An entry without an mtime (an
|
||||
* older server, the in-container source) sorts after every dated one and then
|
||||
* by name, so a listing never degrades into an unstable order.
|
||||
*
|
||||
* ⚠️ This re-orders the listing the SERVER returned, and the server cuts at
|
||||
* FILESYSTEM_PICKER_ENTRY_LIMIT (500) after sorting by name. So in a folder past
|
||||
* that limit, "Newest first" is the newest of the first 500 BY NAME, not the newest
|
||||
* in the folder, which is the one case this sort exists for. The status line says
|
||||
* "(first 500 by name)" rather than "(first 500)" so the cut is legible; ordering
|
||||
* before the cut would have to happen server-side, and would cost a stat on every
|
||||
* entry in the directory rather than on the 500 that are returned.
|
||||
*/
|
||||
_sortEntries(entries) {
|
||||
const [key, direction] = this._sortMode.split('-');
|
||||
const sign = direction === 'desc' ? -1 : 1;
|
||||
const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
return entries.slice().sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
if (key === 'mtime') {
|
||||
const aTime = typeof a.mtimeMs === 'number' ? a.mtimeMs : null;
|
||||
const bTime = typeof b.mtimeMs === 'number' ? b.mtimeMs : null;
|
||||
if (aTime !== null && bTime !== null && aTime !== bTime) return sign * (aTime - bTime);
|
||||
if (aTime === null && bTime !== null) return 1;
|
||||
if (aTime !== null && bTime === null) return -1;
|
||||
return byName(a, b);
|
||||
}
|
||||
return sign * byName(a, b);
|
||||
});
|
||||
},
|
||||
|
||||
/** Compact modified-time label: time of day today, month-day this year, else the date. */
|
||||
_formatModified(mtimeMs) {
|
||||
if (typeof mtimeMs !== 'number' || !Number.isFinite(mtimeMs)) return '';
|
||||
const date = new Date(mtimeMs);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const now = new Date();
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
if (date.toDateString() === now.toDateString()) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
if (date.getFullYear() === now.getFullYear()) return `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Go to a path the user typed. A file path lands in its folder with the
|
||||
* file selected, so pasting a full path from a log or a message is one Enter
|
||||
* away from Select. A path that does not resolve lands in its parent folder
|
||||
* when that exists (the closest place to stand) and otherwise keeps the
|
||||
* current listing, and says so either way — unlike a stale initialPath, a
|
||||
* typo is not a reason to throw the user back to the root.
|
||||
*/
|
||||
jumpTo(rawPath) {
|
||||
const path = String(rawPath || '').trim();
|
||||
if (!path) return;
|
||||
this.load(path, { typed: true });
|
||||
},
|
||||
|
||||
async load(path, options = {}) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
const typed = !!options.typed;
|
||||
if (!typed) list.replaceChildren();
|
||||
status.classList.remove('error');
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -194,13 +316,40 @@ const PathPicker = {
|
||||
if (!result?.success) throw new Error(result?.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
if (options.selectIfListed) {
|
||||
// Landed in the typed path's folder: select the entry if it is there
|
||||
// (a file path), otherwise say what the server said about the full
|
||||
// path — the listing is still the closest place to stand.
|
||||
if (this._entries.some((entry) => entry.path === options.selectIfListed)) {
|
||||
this.select(options.selectIfListed);
|
||||
} else {
|
||||
status.textContent = options.failMessage || 'Path not found';
|
||||
status.classList.add('error');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
const message = error.message || 'Failed to browse this folder';
|
||||
if (typed) {
|
||||
// The browse endpoint answers a FILE path with "not found" (it resolves
|
||||
// folders only), so one retry lands in the parent folder and selects
|
||||
// the entry from the listing. Only one level: a typo two segments up
|
||||
// is an error, not a reason to climb to the root.
|
||||
const slash = path.lastIndexOf('/');
|
||||
if (slash > 0 && !options.selectIfListed) {
|
||||
this.load(path.slice(0, slash), { typed: true, selectIfListed: path, failMessage: message });
|
||||
return;
|
||||
}
|
||||
this.renderEntries();
|
||||
status.textContent = options.failMessage || message;
|
||||
status.classList.add('error');
|
||||
return;
|
||||
}
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.textContent = message;
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
@@ -216,19 +365,29 @@ const PathPicker = {
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
this._currentPath = data.path;
|
||||
this.overlay.querySelector('.path-picker-current').value = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
this._entries = Array.isArray(data.entries) ? data.entries : [];
|
||||
this._truncated = !!data.truncated;
|
||||
this.renderEntries();
|
||||
},
|
||||
|
||||
/** (Re)build the list from the last listing in the current sort order. */
|
||||
renderEntries() {
|
||||
if (!this.overlay) return;
|
||||
const entries = this._sortEntries(this._entries);
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
status.textContent = entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
: `${entries.length} item${entries.length === 1 ? '' : 's'}${this._truncated ? ' (first 500 by name)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
for (const entry of entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
@@ -248,6 +407,14 @@ const PathPicker = {
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
const modified = this._formatModified(entry.mtimeMs);
|
||||
if (modified) {
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'path-picker-item-meta';
|
||||
meta.textContent = modified;
|
||||
meta.title = new Date(entry.mtimeMs).toLocaleString();
|
||||
open.appendChild(meta);
|
||||
}
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
@@ -417,6 +584,8 @@ const PathPicker = {
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
this._currentPath = '';
|
||||
this._entries = [];
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2314,6 +2314,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): a teammate/subagent pane is always
|
||||
// running an agent CLI (Task-tool dispatched, never a plain shell), so
|
||||
// unlike the main terminal's mode-gated trap this one is unconditional.
|
||||
// Mirrors the main terminal's guard in terminal-ui.js's
|
||||
// attachCustomKeyEventHandler — case-insensitive so Caps Lock (which
|
||||
// flips ev.key to 'Z' without setting shiftKey) can't slip a suspend past it.
|
||||
terminal.attachCustomKeyEventHandler((ev) => {
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey
|
||||
) {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wait for terminal renderer to fully initialize before any writes.
|
||||
// xterm.js needs a few frames after open() before write() is safe.
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -13617,24 +13617,95 @@ body.touch-device.cjk-input-visible .main {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.path-picker-current {
|
||||
/* The current path is an editable field: type or paste a path and press Enter
|
||||
* (or Go) to jump there. The Go button is part of the same rounded control so
|
||||
* the field keeps the width the read-only breadcrumb had. */
|
||||
.path-picker-jump {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
overflow-x: auto;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-status {
|
||||
padding: 0 14px 8px;
|
||||
.path-picker-jump:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.path-picker-go {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 10px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-go:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 14px 8px;
|
||||
}
|
||||
|
||||
.path-picker-status {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-sort-label {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.path-picker-sort {
|
||||
padding: 3px 6px;
|
||||
color: var(--text);
|
||||
font-size: 0.7rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.path-picker-item-meta {
|
||||
flex: 0 0 auto;
|
||||
margin-left: 8px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.65rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-status.error {
|
||||
|
||||
@@ -383,6 +383,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ctrl+Z (SIGTSTP/job-control suspend): in a plain shell session this is the
|
||||
// user's own job-control tool (suspend a foreground command, `fg` it back) —
|
||||
// leave it alone. In every other mode (claude/omp/pi/codex/... — Ink/TUI apps
|
||||
// that normally run in raw mode with ISIG off, so ^Z is usually inert there
|
||||
// already) suspending the CLI stops an unattended agent loop dead with no
|
||||
// visible output — the same failure shape as an XOFF freeze. Swallow it
|
||||
// before xterm can send \x1a into the PTY rather than relying on every CLI's
|
||||
// raw-mode state holding at every instant (startup, raw-mode toggles, etc).
|
||||
if (
|
||||
ev.type === 'keydown' &&
|
||||
ev.key.toLowerCase() === 'z' &&
|
||||
ev.ctrlKey &&
|
||||
!ev.altKey &&
|
||||
!ev.metaKey &&
|
||||
!ev.shiftKey
|
||||
) {
|
||||
const activeCtrlZSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
|
||||
if (activeCtrlZSession && activeCtrlZSession.mode !== 'shell') {
|
||||
ev.preventDefault();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+Enter / Ctrl+Enter: insert newline for multi-line input.
|
||||
// xterm.js sends plain \r for all Enter variants, so Claude Code (Ink) can't
|
||||
// distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a)
|
||||
|
||||
@@ -876,6 +876,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
let mtimeMs: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
@@ -886,6 +887,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
mtimeMs = targetStat.mtimeMs;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -894,11 +896,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
if (mtimeMs === undefined) {
|
||||
// One stat per entry: the modified time lets the picker sort by date, and
|
||||
// a file's size rides along on the same call.
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
mtimeMs = targetStat.mtimeMs;
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
// The path is still selectable even when a stat races a change.
|
||||
}
|
||||
}
|
||||
entries.push({
|
||||
@@ -906,6 +912,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
mtimeMs,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* @fileoverview Path picker: sort order and the editable path field.
|
||||
*
|
||||
* Same jsdom harness as path-picker-hidden.test.ts: keyboard-accessory.js is
|
||||
* evaluated against a jsdom window with a scripted fetch, so the assertions
|
||||
* run against the real DOM the picker builds rather than string matches.
|
||||
* Port: N/A
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const SORT_KEY = 'codeman:pathPickerSort';
|
||||
|
||||
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
|
||||
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||
const jsdomDocument = jsdomWindow.document;
|
||||
|
||||
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
|
||||
const MobileDetection = { isTouchDevice: () => false };
|
||||
const factory = new Function(
|
||||
'window',
|
||||
'document',
|
||||
'localStorage',
|
||||
'fetch',
|
||||
'MobileDetection',
|
||||
`${accessoryJs}\nreturn PathPicker;`
|
||||
);
|
||||
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
|
||||
}
|
||||
|
||||
type Entry = { name: string; type: 'file' | 'directory'; mtimeMs?: number };
|
||||
|
||||
function browseResponse(entries: Entry[], path = '/home/dev/project') {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
path,
|
||||
parent: path === '/home/dev' ? null : '/home/dev',
|
||||
root: '/home/dev',
|
||||
roots: [{ label: 'Home', path: '/home/dev' }],
|
||||
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
|
||||
truncated: false,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(error: string) {
|
||||
return { ok: false, json: async () => ({ success: false, error }) };
|
||||
}
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW = Date.now();
|
||||
const LISTING: Entry[] = [
|
||||
{ name: 'zeta.txt', type: 'file', mtimeMs: NOW - 3 * DAY },
|
||||
{ name: 'alpha.txt', type: 'file', mtimeMs: NOW - 1 * DAY },
|
||||
{ name: 'mid.txt', type: 'file', mtimeMs: NOW - 2 * DAY },
|
||||
{ name: 'old-dir', type: 'directory', mtimeMs: NOW - 30 * DAY },
|
||||
{ name: 'new-dir', type: 'directory', mtimeMs: NOW - 1000 },
|
||||
];
|
||||
|
||||
describe('PathPicker sort order', () => {
|
||||
let PathPicker: any;
|
||||
let urls: string[];
|
||||
let respond: (url: string) => unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jsdomWindow.localStorage.clear();
|
||||
jsdomDocument.body.replaceChildren();
|
||||
urls = [];
|
||||
respond = () => browseResponse(LISTING);
|
||||
PathPicker = loadPathPicker(async (url: string) => {
|
||||
urls.push(url);
|
||||
return respond(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PathPicker?.close?.(false);
|
||||
jsdomDocument.body.replaceChildren();
|
||||
});
|
||||
|
||||
const open = async (options: Record<string, unknown> = {}) => {
|
||||
PathPicker.open({ onSelect: () => {}, ...options });
|
||||
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
|
||||
};
|
||||
const names = () => Array.from(jsdomDocument.querySelectorAll('.path-picker-item-name')).map((el) => el.textContent);
|
||||
const sortSelect = () => jsdomDocument.querySelector('.path-picker-sort') as HTMLSelectElement;
|
||||
const setSort = (value: string) => {
|
||||
sortSelect().value = value;
|
||||
sortSelect().dispatchEvent(new jsdomWindow.Event('change', { bubbles: true }));
|
||||
};
|
||||
|
||||
it('sorts by name with folders first by default', async () => {
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
|
||||
});
|
||||
|
||||
it('re-orders the listing without another request, keeping folders first', async () => {
|
||||
await open();
|
||||
const requests = urls.length;
|
||||
|
||||
setSort('mtime-desc');
|
||||
expect(names()).toEqual(['new-dir', 'old-dir', 'alpha.txt', 'mid.txt', 'zeta.txt']);
|
||||
|
||||
setSort('mtime-asc');
|
||||
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
|
||||
|
||||
setSort('name-desc');
|
||||
expect(names()).toEqual(['old-dir', 'new-dir', 'zeta.txt', 'mid.txt', 'alpha.txt']);
|
||||
|
||||
expect(urls.length).toBe(requests);
|
||||
});
|
||||
|
||||
it('remembers the sort mode across reopenings', async () => {
|
||||
await open();
|
||||
setSort('mtime-desc');
|
||||
expect(jsdomWindow.localStorage.getItem(SORT_KEY)).toBe('mtime-desc');
|
||||
PathPicker.close(false);
|
||||
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('mtime-desc');
|
||||
});
|
||||
|
||||
it('ignores a corrupt stored mode and a localStorage that throws', async () => {
|
||||
jsdomWindow.localStorage.setItem(SORT_KEY, 'bogus');
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
PathPicker.close(false);
|
||||
|
||||
const getItem = vi.spyOn(jsdomWindow.localStorage.__proto__, 'getItem').mockImplementation(() => {
|
||||
throw new Error('private mode');
|
||||
});
|
||||
try {
|
||||
await open();
|
||||
expect(sortSelect().value).toBe('name-asc');
|
||||
setSort('mtime-asc');
|
||||
expect(names()[0]).toBe('old-dir');
|
||||
} finally {
|
||||
getItem.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('places entries without a modified time after dated ones on a date sort', async () => {
|
||||
respond = () =>
|
||||
browseResponse([
|
||||
{ name: 'undated.txt', type: 'file' },
|
||||
{ name: 'dated.txt', type: 'file', mtimeMs: NOW - DAY },
|
||||
]);
|
||||
await open();
|
||||
setSort('mtime-desc');
|
||||
expect(names()).toEqual(['dated.txt', 'undated.txt']);
|
||||
setSort('mtime-asc');
|
||||
expect(names()).toEqual(['dated.txt', 'undated.txt']);
|
||||
});
|
||||
|
||||
it('shows a compact modified time only when the server supplied one', async () => {
|
||||
respond = () =>
|
||||
browseResponse([
|
||||
{ name: 'undated.txt', type: 'file' },
|
||||
{ name: 'today.txt', type: 'file', mtimeMs: NOW },
|
||||
]);
|
||||
await open();
|
||||
const rows = Array.from(jsdomDocument.querySelectorAll('.path-picker-item'));
|
||||
const meta = (row: Element) => row.querySelector('.path-picker-item-meta')?.textContent ?? null;
|
||||
expect(meta(rows[0])).toMatch(/^\d{2}:\d{2}$/);
|
||||
expect(meta(rows[1])).toBeNull();
|
||||
});
|
||||
|
||||
it('styles the sort control and the modified column', () => {
|
||||
expect(stylesCss).toContain('.path-picker-sort {');
|
||||
expect(stylesCss).toContain('.path-picker-item-meta {');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PathPicker editable path', () => {
|
||||
let PathPicker: any;
|
||||
let urls: string[];
|
||||
let respond: (url: string) => unknown;
|
||||
|
||||
beforeEach(() => {
|
||||
jsdomWindow.localStorage.clear();
|
||||
jsdomDocument.body.replaceChildren();
|
||||
urls = [];
|
||||
respond = () => browseResponse(LISTING);
|
||||
PathPicker = loadPathPicker(async (url: string) => {
|
||||
urls.push(url);
|
||||
return respond(url);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
PathPicker?.close?.(false);
|
||||
jsdomDocument.body.replaceChildren();
|
||||
});
|
||||
|
||||
const open = async (options: Record<string, unknown> = {}) => {
|
||||
PathPicker.open({ onSelect: () => {}, ...options });
|
||||
await vi.waitFor(() => expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
|
||||
};
|
||||
const field = () => jsdomDocument.querySelector('.path-picker-current') as HTMLInputElement;
|
||||
const submit = (value: string) => {
|
||||
field().value = value;
|
||||
(jsdomDocument.querySelector('.path-picker-jump') as HTMLFormElement).dispatchEvent(
|
||||
new jsdomWindow.Event('submit', { bubbles: true, cancelable: true })
|
||||
);
|
||||
};
|
||||
const pathParam = (url: string) => new URL(url, 'https://localhost').searchParams.get('path');
|
||||
const status = () => jsdomDocument.querySelector('.path-picker-status') as HTMLElement;
|
||||
|
||||
it('shows the current folder in an editable field and jumps on Enter', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
expect(field().value).toBe('/home/dev/project');
|
||||
const before = urls.length;
|
||||
|
||||
// Typing alone never fetches.
|
||||
field().value = '/home/dev/oth';
|
||||
field().dispatchEvent(new jsdomWindow.Event('input', { bubbles: true }));
|
||||
expect(urls.length).toBe(before);
|
||||
|
||||
respond = () => browseResponse([{ name: 'readme.md', type: 'file' }], '/home/dev/other');
|
||||
submit(' /home/dev/other ');
|
||||
await vi.waitFor(() => expect(field().value).toBe('/home/dev/other'));
|
||||
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/other');
|
||||
expect(jsdomDocument.querySelector('.path-picker-item-name')?.textContent).toBe('readme.md');
|
||||
});
|
||||
|
||||
it('keeps the current listing and reports the error when neither a typed path nor its parent resolves', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
respond = () => errorResponse('Path not found: /home/dev/nope/deeper');
|
||||
submit('/home/dev/nope/deeper');
|
||||
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
|
||||
expect(status().textContent).toBe('Path not found: /home/dev/nope/deeper');
|
||||
// One retry on the parent, then stop: never a climb to the root.
|
||||
expect(urls.slice(-2).map(pathParam)).toEqual(['/home/dev/nope/deeper', '/home/dev/nope']);
|
||||
expect(jsdomDocument.querySelectorAll('.path-picker-item').length).toBe(LISTING.length);
|
||||
// The typed text stays in the field so the typo can be corrected in place.
|
||||
expect(field().value).toBe('/home/dev/nope/deeper');
|
||||
});
|
||||
|
||||
it('lands in the parent folder, unselected, when only the last segment is wrong', async () => {
|
||||
await open({ initialPath: '/home/dev' });
|
||||
respond = (url) =>
|
||||
pathParam(url) === '/home/dev/project/typo.txt'
|
||||
? errorResponse('Path not found: /home/dev/project/typo.txt')
|
||||
: browseResponse(LISTING);
|
||||
submit('/home/dev/project/typo.txt');
|
||||
await vi.waitFor(() => expect(field().value).toBe('/home/dev/project'));
|
||||
await vi.waitFor(() => expect(status().classList.contains('error')).toBe(true));
|
||||
expect(status().textContent).toBe('Path not found: /home/dev/project/typo.txt');
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('None');
|
||||
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('lands a typed file path in its folder with the file selected', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
respond = (url) =>
|
||||
pathParam(url) === '/home/dev/project/alpha.txt'
|
||||
? errorResponse('Path not found: /home/dev/project/alpha.txt')
|
||||
: browseResponse(LISTING);
|
||||
submit('/home/dev/project/alpha.txt');
|
||||
await vi.waitFor(() =>
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe(
|
||||
'/home/dev/project/alpha.txt'
|
||||
)
|
||||
);
|
||||
expect(field().value).toBe('/home/dev/project');
|
||||
expect(jsdomDocument.querySelector('.path-picker-item.selected .path-picker-item-name')?.textContent).toBe(
|
||||
'alpha.txt'
|
||||
);
|
||||
expect((jsdomDocument.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the current folder from the field value and refreshes in place', async () => {
|
||||
await open({ initialPath: '/home/dev/project' });
|
||||
(jsdomDocument.querySelector('.path-picker-current-select') as HTMLButtonElement).click();
|
||||
expect(jsdomDocument.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project');
|
||||
|
||||
const before = urls.length;
|
||||
(jsdomDocument.querySelector('.path-picker-refresh') as HTMLButtonElement).click();
|
||||
await vi.waitFor(() => expect(urls.length).toBe(before + 1));
|
||||
expect(pathParam(urls[urls.length - 1])).toBe('/home/dev/project');
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,50 @@ describe('file-routes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('stamps every entry with its modified time so the picker can sort by date', async () => {
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{ name: 'notes.txt', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
|
||||
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
|
||||
{ name: 'link', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
|
||||
] as never);
|
||||
mockedStat.mockImplementation(async (candidate) => {
|
||||
const target = String(candidate);
|
||||
if (target.endsWith('/notes.txt')) {
|
||||
return { size: 42, mtimeMs: 1_700_000_000_000, isFile: () => true, isDirectory: () => false } as never;
|
||||
}
|
||||
if (target.endsWith('/src')) {
|
||||
return { size: 4096, mtimeMs: 1_700_000_001_000, isFile: () => false, isDirectory: () => true } as never;
|
||||
}
|
||||
if (target.endsWith('/link')) {
|
||||
return { size: 7, mtimeMs: 1_700_000_002_000, isFile: () => true, isDirectory: () => false } as never;
|
||||
}
|
||||
return { size: 0, mtimeMs: 0, isFile: () => false, isDirectory: () => true } as never;
|
||||
});
|
||||
|
||||
const path = harness.ctx._session.workingDir;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const entries = JSON.parse(res.body).data.entries as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
mtimeMs?: number;
|
||||
}>;
|
||||
expect(entries.map((entry) => [entry.name, entry.type, entry.size, entry.mtimeMs])).toEqual([
|
||||
['src', 'directory', undefined, 1_700_000_001_000],
|
||||
['link', 'file', 7, 1_700_000_002_000],
|
||||
['notes.txt', 'file', 42, 1_700_000_000_000],
|
||||
]);
|
||||
// One stat per entry: the date and the size ride on the same call.
|
||||
const statsFor = (name: string) =>
|
||||
mockedStat.mock.calls.filter(([candidate]) => String(candidate).endsWith(`/${name}`)).length;
|
||||
expect([statsFor('notes.txt'), statsFor('src'), statsFor('link')]).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it('defaults to the Codeman Cases root, not Home, when linking a case with no path chosen yet', async () => {
|
||||
// The "Link Existing" case picker opens with an empty path and no
|
||||
// sessionId. `Home` and `Codeman Cases` are unrelated bind mounts under
|
||||
|
||||
@@ -295,6 +295,13 @@ describe('TmuxManager (unit)', () => {
|
||||
expect(callBuildEnvExports('opencode')).toContain('unset COLORTERM');
|
||||
});
|
||||
|
||||
// Claude renders its themed backgrounds as RGB. Without this the pane inherits
|
||||
// tmux's TERM=screen, supports-color reads 16 colors, and every dark background
|
||||
// quantizes to ESC[40m — the terminal's own black — so the block goes invisible.
|
||||
it('exports truecolor for Claude sessions', () => {
|
||||
expect(callBuildEnvExports('claude')).toContain('export COLORTERM=truecolor');
|
||||
});
|
||||
|
||||
it('exports the server-stamped CODEMAN_API_URL verbatim', () => {
|
||||
const original = process.env.CODEMAN_API_URL;
|
||||
process.env.CODEMAN_API_URL = 'https://127.0.0.1:3199';
|
||||
|
||||
Reference in New Issue
Block a user