diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
index 13fb807..66f32d0 100644
--- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
+++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift
@@ -452,7 +452,12 @@ public enum GraphcodeCommand: Equatable, Sendable {
])
let flags = parseFlags(arguments)
if flags["help"] != nil { throw HelpRequested() }
- guard let title = flags["title"] else { throw ParseError.missingArgument("--title") }
+ guard let rawTitle = flags["title"] else { throw ParseError.missingArgument("--title") }
+ // A loop fanning work out types the name it would say out loud — "Board Visibility" —
+ // and the instruction to write it as one word is only ever an instruction. Folded
+ // here, at the boundary, so a CLI-created loop is named the way every other one is
+ // (see `LoopName`).
+ let title = LoopName.folded(rawTitle) ?? rawTitle
guard let rawType = flags["type"] else { throw ParseError.missingArgument("--type") }
let loopType: LoopType
diff --git a/GraphcodeKit/Sources/Domain/LoopName.swift b/GraphcodeKit/Sources/Domain/LoopName.swift
new file mode 100644
index 0000000..1ece8ee
--- /dev/null
+++ b/GraphcodeKit/Sources/Domain/LoopName.swift
@@ -0,0 +1,23 @@
+import Foundation
+
+/// The one-word shape every loop name on a canvas has. A name is read in a sidebar row
+/// and a card header, where one word carries further, so two concepts join in CamelCase
+/// — "BoardVisibility" — instead of spending a space on them.
+///
+/// The backend namer asks for that shape and folds what it gets back
+/// (`TitleSuggestionClient.sanitize`). This is the same fold applied where a name arrives
+/// already written: `--title` on the CLI, typed by a loop fanning work out, where telling
+/// is all the instruction can do.
+public enum LoopName {
+ /// `nil` when nothing alphanumeric survives, which is the caller's cue to keep whatever
+ /// it was given rather than create a nameless card.
+ public static func folded(_ raw: String) -> String? {
+ let words =
+ raw
+ .split(whereSeparator: { $0.isWhitespace })
+ .map { $0.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) }
+ .filter { !$0.isEmpty }
+ guard !words.isEmpty else { return nil }
+ return words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined()
+ }
+}
diff --git a/GraphcodeKit/Sources/Domain/SessionBriefing.swift b/GraphcodeKit/Sources/Domain/SessionBriefing.swift
index ef2995d..3b9755d 100644
--- a/GraphcodeKit/Sources/Domain/SessionBriefing.swift
+++ b/GraphcodeKit/Sources/Domain/SessionBriefing.swift
@@ -133,7 +133,7 @@ public enum SessionBriefing {
in sequence:
```sh
- graphcode node create \(projectPath) --title
--type goal --goal
+ graphcode node create \(projectPath) --title --type goal --goal
```
A **node is a loop** — the cards on graphcode's canvas are loops, and the CLI calls
@@ -256,7 +256,8 @@ public enum SessionBriefing {
If `graphcode` is not on your `PATH`, it is at `\(installedCLIPath)`.
Give each loop a title that says what it is for and a goal that says what done looks
- like — they are what a human sees in the sidebar.
+ like — they are what a human sees in the sidebar. A title is one word: two concepts
+ join in CamelCase, `--title BoardVisibility`, never `--title "Board Visibility"`.
## When not to
diff --git a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
index acaa018..ee94fb9 100644
--- a/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
+++ b/GraphcodeKit/Sources/Sessions/RemoteGraphAccess.swift
@@ -452,6 +452,20 @@ public enum RemoteGraphAccess {
print(acknowledgement)
+ def folded_title(raw):
+ # The one-word CamelCase shape every loop name has -- LoopName.folded on the Mac.
+ words = []
+ for word in raw.split():
+ start, end = 0, len(word)
+ while start < end and not word[start].isalnum():
+ start += 1
+ while end > start and not word[end - 1].isalnum():
+ end -= 1
+ if end > start:
+ words.append(word[start].upper() + word[start + 1:end])
+ return "".join(words) or raw.strip()
+
+
def make_draft(flags):
if not flags.get("title"):
fail("missing --title")
@@ -471,7 +485,7 @@ public enum RemoteGraphAccess {
fail("a %s loop needs --%s" % (flags["type"], needed))
draft = {
"id": str(uuid.uuid4()).upper(),
- "title": flags["title"],
+ "title": folded_title(flags["title"]),
"loopType": loop_type,
"pausesBeforeWritesOnly": False,
}
diff --git a/graphcode/Sources/Clients/TitleSuggestionClient.swift b/graphcode/Sources/Clients/TitleSuggestionClient.swift
index b51915a..8bfbacd 100644
--- a/graphcode/Sources/Clients/TitleSuggestionClient.swift
+++ b/graphcode/Sources/Clients/TitleSuggestionClient.swift
@@ -153,12 +153,8 @@ extension TitleSuggestionClient: DependencyKey {
.components(separatedBy: .newlines)
.map { $0.trimmingCharacters(in: .whitespaces) }
.last { !$0.isEmpty }
- let words = (lastLine ?? "").split(separator: " ").prefix(2)
- .map { $0.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) }
- .filter { !$0.isEmpty }
- guard !words.isEmpty else { return nil }
- let name = words.map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined()
- guard name.count <= 30 else { return nil }
+ let answer = (lastLine ?? "").split(separator: " ").prefix(2).joined(separator: " ")
+ guard let name = LoopName.folded(answer), name.count <= 30 else { return nil }
return name
}
}
diff --git a/graphcode/Tests/GraphcodeCommandTests.swift b/graphcode/Tests/GraphcodeCommandTests.swift
index 3a6369a..aa7bb02 100644
--- a/graphcode/Tests/GraphcodeCommandTests.swift
+++ b/graphcode/Tests/GraphcodeCommandTests.swift
@@ -459,6 +459,26 @@ extension GraphcodeCommandTests {
#expect(draft.title == "Classify")
}
+ @Test
+ func aTitleWrittenAsSeparateWordsIsFoldedIntoOne() throws {
+ // What a loop fanning work out actually types. Every other loop name on the canvas
+ // is one word, and telling the session so is all the briefing can do.
+ let command = try GraphcodeCommand.parse([
+ "node", "create", "/tmp/x", "--title", "Board Visibility", "--type", "goal",
+ "--goal", "boards ship",
+ ])
+ guard case .createNode(_, let draft, _) = command else {
+ Issue.record("expected createNode, got \(command)")
+ return
+ }
+ #expect(draft.title == "BoardVisibility")
+ #expect(LoopName.folded("fix the flaky login test") == "FixTheFlakyLoginTest")
+ #expect(LoopName.folded("GraphCode templates") == "GraphCodeTemplates")
+ // Nothing alphanumeric to fold: the caller keeps what it was given rather than
+ // creating a nameless card.
+ #expect(LoopName.folded("---") == nil)
+ }
+
@Test
func compositeIsCreatableFromTheCLIUnderEitherName() throws {
// `proactive` too: that is what a composite still serialises as, so it is the word a
diff --git a/graphcode/Tests/RemoteCLIShimTests.swift b/graphcode/Tests/RemoteCLIShimTests.swift
index 7acbcc9..112ba0d 100644
--- a/graphcode/Tests/RemoteCLIShimTests.swift
+++ b/graphcode/Tests/RemoteCLIShimTests.swift
@@ -51,7 +51,8 @@ struct RemoteCLIShimTests {
return
}
#expect(path == Self.project)
- #expect(draft.title == "Fix issue 7")
+ // Folded to one word, exactly as `LoopName.folded` does it on the Mac.
+ #expect(draft.title == "FixIssue7")
#expect(draft.loopType == .goalBased)
#expect(draft.goal?.summary == "tests pass")
#expect(draft.goal?.predicate == "make test")